Implement meeting assistant v1

This commit is contained in:
2026-05-20 02:06:16 +02:00
parent 90df1edc03
commit 0297bcc0f6
120 changed files with 11883 additions and 180 deletions
@@ -0,0 +1,14 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Summary;
public interface IMeetingSummaryPipeline
{
Task<MeetingSummaryRunResult> RunAsync(MeetingSessionArtifacts artifacts, CancellationToken cancellationToken);
}
public sealed record MeetingSummaryRunResult(
string SummaryPath,
string AgentResponse,
bool Succeeded = true,
string? Error = null);
@@ -0,0 +1,618 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace MeetingAssistant.Summary;
#pragma warning disable MAAI001
public sealed class LiteLlmResponsesChatClient : IChatClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient httpClient;
private readonly string model;
private readonly bool enableThinking;
private readonly string reasoningEffort;
private readonly int reconnectionAttempts;
private readonly TimeSpan reconnectionDelay;
private readonly LiteLlmResponsesCompactionOptions? compactionOptions;
private readonly ILogger? logger;
public LiteLlmResponsesChatClient(
Uri endpoint,
string apiKey,
string model,
bool enableThinking,
string reasoningEffort,
int reconnectionAttempts,
TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null)
: this(
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
apiKey,
model,
enableThinking,
reasoningEffort,
reconnectionAttempts,
reconnectionDelay,
compactionOptions,
logger)
{
}
internal LiteLlmResponsesChatClient(
HttpClient httpClient,
string apiKey,
string model,
bool enableThinking,
string reasoningEffort,
int reconnectionAttempts,
TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null)
{
this.httpClient = httpClient;
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
this.model = model;
this.enableThinking = enableThinking;
this.reasoningEffort = reasoningEffort;
this.reconnectionAttempts = Math.Max(0, reconnectionAttempts);
this.reconnectionDelay = reconnectionDelay > TimeSpan.Zero ? reconnectionDelay : TimeSpan.Zero;
this.compactionOptions = compactionOptions;
this.logger = logger;
}
public void Dispose()
{
httpClient.Dispose();
}
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var payload = await CreateCompactedPayloadAsync(messages.ToList(), options, cancellationToken).ConfigureAwait(false);
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
var response = ParseResponseJson(responseJson);
LogResponseUsage(response.Usage);
return response;
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
foreach (var message in response.Messages)
{
yield return new ChatResponseUpdate(message.Role, message.Contents);
}
}
public object? GetService(Type serviceType, object? serviceKey = null)
{
if (serviceKey is not null)
{
return null;
}
return serviceType == typeof(ChatClientMetadata)
? new ChatClientMetadata("litellm-responses", httpClient.BaseAddress, model)
: null;
}
public static ChatResponse ParseResponseJson(string responseJson)
{
using var document = JsonDocument.Parse(responseJson);
var root = document.RootElement;
var contents = new List<AIContent>();
if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
{
foreach (var item in output.EnumerateArray())
{
var type = GetString(item, "type");
if (type == "message")
{
AddMessageContent(contents, item);
}
else if (type == "function_call")
{
contents.Add(new FunctionCallContent(
GetRequiredString(item, "call_id"),
GetRequiredString(item, "name"),
ParseArguments(GetString(item, "arguments"))));
}
}
}
var message = new ChatMessage
{
Role = ChatRole.Assistant,
Contents = contents
};
return new ChatResponse(message)
{
ResponseId = GetString(root, "id"),
ModelId = GetString(root, "model"),
CreatedAt = GetUnixTimestamp(root, "created_at"),
Usage = ParseUsage(root),
RawRepresentation = responseJson
};
}
private async Task<JsonObject> CreateCompactedPayloadAsync(
IReadOnlyList<ChatMessage> messages,
ChatOptions? options,
CancellationToken cancellationToken)
{
var payload = CreatePayload(messages, options);
var estimate = EstimateTokens(payload);
var triggerTokens = compactionOptions?.TriggerTokens ?? int.MaxValue;
if (compactionOptions?.Enabled != true || estimate < triggerTokens)
{
LogContextWindow(estimate, compacted: false, source: "none");
return payload;
}
LogContextWindow(estimate, compacted: false, source: "threshold");
var remotePayload = await TryCompactWithResponsesEndpointAsync(payload, estimate, cancellationToken)
.ConfigureAwait(false);
if (remotePayload is not null)
{
LogContextWindow(EstimateTokens(remotePayload), compacted: true, source: "responses_compact");
return remotePayload;
}
if (compactionOptions.FallbackStrategy is null)
{
return payload;
}
try
{
var compactedMessages = await CompactionProvider.CompactAsync(
compactionOptions.FallbackStrategy,
messages,
logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
cancellationToken)
.ConfigureAwait(false);
var fallbackPayload = CreatePayload(compactedMessages, options);
LogContextWindow(EstimateTokens(fallbackPayload), compacted: true, source: "agent_framework");
return fallbackPayload;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger?.LogWarning(
exception,
"Agent Framework compaction fallback failed; sending un-compacted summary request");
return payload;
}
}
private async Task<JsonObject?> TryCompactWithResponsesEndpointAsync(
JsonObject payload,
int estimatedTokens,
CancellationToken cancellationToken)
{
try
{
var request = new JsonObject
{
["model"] = payload["model"]?.DeepClone(),
["input"] = payload["input"]?.DeepClone(),
["instructions"] = payload["instructions"]?.DeepClone(),
["context_window_tokens"] = compactionOptions!.ContextWindowTokens,
["max_output_tokens"] = compactionOptions.MaxOutputTokens,
["estimated_input_tokens"] = estimatedTokens,
["target_input_tokens"] = compactionOptions.TriggerTokens,
["strategy"] = new JsonArray
{
"collapse_tool_results",
"summarize_older_spans",
"keep_last_4_user_turns",
"drop_oldest_groups"
}
};
using var content = new StringContent(request.ToJsonString(JsonOptions), Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync(
NormalizeRelativePath(compactionOptions.CompactPath),
content,
cancellationToken)
.ConfigureAwait(false);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
logger?.LogWarning(
"Responses compaction endpoint failed with {StatusCode} {ReasonPhrase}: {Body}",
(int)response.StatusCode,
response.ReasonPhrase,
responseJson);
return null;
}
return ApplyCompactedInput(payload, responseJson);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger?.LogWarning(exception, "Responses compaction endpoint failed; falling back to Agent Framework compaction");
return null;
}
}
private static JsonObject? ApplyCompactedInput(JsonObject payload, string responseJson)
{
using var document = JsonDocument.Parse(responseJson);
var root = document.RootElement;
var input = FindCompactedInput(root);
if (input is null)
{
return null;
}
var compactedPayload = (JsonObject)payload.DeepClone();
compactedPayload["input"] = JsonNode.Parse(input.Value.GetRawText());
if (root.TryGetProperty("instructions", out var instructions) && instructions.ValueKind == JsonValueKind.String)
{
compactedPayload["instructions"] = instructions.GetString();
}
return compactedPayload;
}
private static JsonElement? FindCompactedInput(JsonElement root)
{
foreach (var propertyName in new[] { "input", "compacted_input", "messages", "output" })
{
if (root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array)
{
return value;
}
}
return null;
}
private JsonObject CreatePayload(IEnumerable<ChatMessage> messages, ChatOptions? options)
{
var instructions = new StringBuilder();
if (!string.IsNullOrWhiteSpace(options?.Instructions))
{
instructions.AppendLine(options.Instructions);
}
var input = new JsonArray();
foreach (var message in messages)
{
AddInputItem(input, instructions, message);
}
var payload = new JsonObject
{
["model"] = options?.ModelId ?? model,
["input"] = input,
["store"] = false,
["tool_choice"] = "auto"
};
if (instructions.Length > 0)
{
payload["instructions"] = instructions.ToString().Trim();
}
if (enableThinking)
{
payload["reasoning"] = new JsonObject
{
["effort"] = reasoningEffort
};
}
var tools = CreateTools(options?.Tools);
if (tools.Count > 0)
{
payload["tools"] = tools;
payload["parallel_tool_calls"] = true;
}
return payload;
}
private async Task<string> PostWithRetryAsync(JsonObject payload, CancellationToken cancellationToken)
{
var payloadJson = payload.ToJsonString(JsonOptions);
Exception? lastException = null;
for (var attempt = 0; attempt <= reconnectionAttempts; attempt++)
{
try
{
using var content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync("responses", content, cancellationToken).ConfigureAwait(false);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return responseJson;
}
var exception = new InvalidOperationException(
$"LiteLLM Responses request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {responseJson}");
if (!IsRetryableStatusCode((int)response.StatusCode) || attempt == reconnectionAttempts)
{
throw exception;
}
lastException = exception;
}
catch (Exception exception) when (IsRetryableException(exception) && attempt < reconnectionAttempts)
{
lastException = exception;
}
await DelayBeforeRetryAsync(cancellationToken).ConfigureAwait(false);
}
throw lastException ?? new InvalidOperationException("LiteLLM Responses request failed.");
}
private async Task DelayBeforeRetryAsync(CancellationToken cancellationToken)
{
if (reconnectionDelay > TimeSpan.Zero)
{
await Task.Delay(reconnectionDelay, cancellationToken).ConfigureAwait(false);
}
}
private static bool IsRetryableStatusCode(int statusCode)
{
return statusCode is 408 or 409 or 425 or 429
|| statusCode >= 500;
}
private static bool IsRetryableException(Exception exception)
{
return exception is HttpRequestException or TaskCanceledException;
}
private void LogContextWindow(int estimatedTokens, bool compacted, string source)
{
if (compactionOptions is null || logger is null)
{
return;
}
var contextWindow = Math.Max(1, compactionOptions.ContextWindowTokens);
var remainingTokens = Math.Max(0, contextWindow - estimatedTokens);
logger.LogInformation(
"Summary context estimate: {EstimatedTokens}/{ContextWindowTokens} tokens, {RemainingTokens} remaining, compacted={Compacted}, source={Source}",
estimatedTokens,
contextWindow,
remainingTokens,
compacted,
source);
}
private void LogResponseUsage(UsageDetails? usage)
{
if (usage is null || compactionOptions is null || logger is null)
{
return;
}
var contextWindow = Math.Max(1, compactionOptions.ContextWindowTokens);
var inputTokens = usage.InputTokenCount ?? 0;
var outputTokens = usage.OutputTokenCount ?? 0;
var totalTokens = usage.TotalTokenCount ?? inputTokens + outputTokens;
logger.LogInformation(
"Summary response usage: input={InputTokens}, output={OutputTokens}, total={TotalTokens}, contextWindow={ContextWindowTokens}",
inputTokens,
outputTokens,
totalTokens,
contextWindow);
}
private static void AddInputItem(JsonArray input, StringBuilder instructions, ChatMessage message)
{
var role = message.Role.Value;
var text = message.Text;
if (role == ChatRole.System.Value)
{
if (!string.IsNullOrWhiteSpace(text))
{
instructions.AppendLine(text);
}
return;
}
if (!string.IsNullOrWhiteSpace(text))
{
input.Add(new JsonObject
{
["role"] = role == ChatRole.Assistant.Value ? "assistant" : "user",
["content"] = text
});
}
foreach (var content in message.Contents)
{
if (content is FunctionCallContent call)
{
input.Add(new JsonObject
{
["type"] = "function_call",
["call_id"] = call.CallId,
["name"] = call.Name,
["arguments"] = JsonSerializer.Serialize(call.Arguments, JsonOptions)
});
}
else if (content is FunctionResultContent result)
{
input.Add(new JsonObject
{
["type"] = "function_call_output",
["call_id"] = result.CallId,
["output"] = ResultToString(result.Result)
});
}
}
}
private static JsonArray CreateTools(IList<AITool>? tools)
{
var result = new JsonArray();
if (tools is null)
{
return result;
}
foreach (var tool in tools.OfType<AIFunctionDeclaration>())
{
result.Add(new JsonObject
{
["type"] = "function",
["name"] = tool.Name,
["description"] = tool.Description ?? string.Empty,
["parameters"] = JsonNode.Parse(tool.JsonSchema.GetRawText())
});
}
return result;
}
private static void AddMessageContent(List<AIContent> contents, JsonElement item)
{
if (!item.TryGetProperty("content", out var messageContent) || messageContent.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var content in messageContent.EnumerateArray())
{
if (GetString(content, "type") == "output_text")
{
var text = GetString(content, "text");
if (!string.IsNullOrEmpty(text))
{
contents.Add(new TextContent(text));
}
}
}
}
private static Dictionary<string, object?> ParseArguments(string? arguments)
{
if (string.IsNullOrWhiteSpace(arguments))
{
return [];
}
using var document = JsonDocument.Parse(arguments);
return document.RootElement.EnumerateObject()
.ToDictionary(property => property.Name, property => ConvertJsonValue(property.Value));
}
private static object? ConvertJsonValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number when element.TryGetInt64(out var integer) => integer,
JsonValueKind.Number when element.TryGetDouble(out var number) => number,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => JsonSerializer.Deserialize<object>(element.GetRawText(), JsonOptions)
};
}
private static string ResultToString(object? result)
{
return result switch
{
null => string.Empty,
string text => text,
_ => JsonSerializer.Serialize(result, JsonOptions)
};
}
private static UsageDetails? ParseUsage(JsonElement root)
{
if (!root.TryGetProperty("usage", out var usage) || usage.ValueKind != JsonValueKind.Object)
{
return null;
}
var inputTokens = GetInt(usage, "input_tokens") ?? GetInt(usage, "prompt_tokens");
var outputTokens = GetInt(usage, "output_tokens") ?? GetInt(usage, "completion_tokens");
var totalTokens = GetInt(usage, "total_tokens");
return new UsageDetails
{
InputTokenCount = inputTokens,
OutputTokenCount = outputTokens,
TotalTokenCount = totalTokens
};
}
private static int EstimateTokens(JsonObject payload)
{
var json = payload.ToJsonString(JsonOptions);
return Math.Max(1, (int)Math.Ceiling(json.Length / 4.0));
}
private static int? GetInt(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value)
? value
: null;
}
private static string GetRequiredString(JsonElement element, string propertyName)
{
return GetString(element, propertyName)
?? throw new InvalidOperationException($"LiteLLM response item did not include '{propertyName}'.");
}
private static string? GetString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static DateTimeOffset? GetUnixTimestamp(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property) && property.TryGetInt64(out var value)
? DateTimeOffset.FromUnixTimeSeconds(value)
: null;
}
private static Uri NormalizeEndpoint(Uri endpoint)
{
var value = endpoint.ToString().TrimEnd('/');
if (!value.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
{
value += "/v1";
}
return new Uri(value + "/");
}
private static string NormalizeRelativePath(string value)
{
return value.TrimStart('/');
}
}
#pragma warning restore MAAI001
@@ -0,0 +1,32 @@
using Microsoft.Agents.AI.Compaction;
namespace MeetingAssistant.Summary;
#pragma warning disable MAAI001
public sealed class LiteLlmResponsesCompactionOptions
{
public bool Enabled { get; init; }
public int ContextWindowTokens { get; init; }
public int MaxOutputTokens { get; init; }
public double RemainingRatio { get; init; }
public string CompactPath { get; init; } = "responses/compact";
public CompactionStrategy? FallbackStrategy { get; init; }
public int TriggerTokens
{
get
{
var contextWindow = Math.Max(1, ContextWindowTokens);
var outputReserve = Math.Clamp(MaxOutputTokens, 0, contextWindow - 1);
var inputBudget = contextWindow - outputReserve;
var remainingRatio = Math.Clamp(RemainingRatio, 0.01, 0.90);
return Math.Max(1, (int)Math.Floor(inputBudget * (1 - remainingRatio)));
}
}
}
#pragma warning restore MAAI001
@@ -0,0 +1,126 @@
using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Summary;
public interface IMeetingSummaryArtifactResolver
{
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
CancellationToken cancellationToken);
}
public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver
{
private readonly MeetingAssistantOptions options;
private readonly IMeetingNoteStore meetingNoteStore;
public MeetingSummaryArtifactResolver(
IOptions<MeetingAssistantOptions> options,
IMeetingNoteStore meetingNoteStore)
{
this.options = options.Value;
this.meetingNoteStore = meetingNoteStore;
}
public async Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
CancellationToken cancellationToken)
{
var requestedSummaryPath = ResolveRequestedSummaryPath(summaryPath);
if (requestedSummaryPath is null)
{
return null;
}
var meetingNotesFolder = VaultPath.Resolve(options.Vault.MeetingNotesFolder);
if (!Directory.Exists(meetingNotesFolder))
{
return null;
}
foreach (var meetingNotePath in Directory.EnumerateFiles(meetingNotesFolder, "*.md"))
{
var note = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
var noteSummaryPath = ResolveNoteLink(note.Frontmatter.Summary, note.Path);
if (!PathsEqual(requestedSummaryPath, noteSummaryPath))
{
continue;
}
return new MeetingSessionArtifacts(
note.Path,
ResolveNoteLink(note.Frontmatter.Transcript, note.Path),
ResolveNoteLink(note.Frontmatter.AssistantContext, note.Path),
requestedSummaryPath);
}
return null;
}
private string? ResolveRequestedSummaryPath(string summaryPath)
{
if (string.IsNullOrWhiteSpace(summaryPath))
{
return null;
}
var summariesFolder = VaultPath.Resolve(options.Vault.SummariesFolder);
var requested = Environment.ExpandEnvironmentVariables(summaryPath);
var fullPath = Path.IsPathRooted(requested)
? Path.GetFullPath(requested)
: Path.GetFullPath(Path.Combine(summariesFolder, requested));
return IsWithinDirectory(summariesFolder, fullPath) ? fullPath : null;
}
private static string ResolveNoteLink(string pathOrLink, string sourceNotePath)
{
if (string.IsNullOrWhiteSpace(pathOrLink))
{
return "";
}
var sourceFolder = Path.GetDirectoryName(sourceNotePath) ?? "";
var target = ExtractWikiLinkTarget(pathOrLink) ?? pathOrLink;
target = target.Replace('/', Path.DirectorySeparatorChar);
if (!Path.HasExtension(target))
{
target += ".md";
}
return Path.GetFullPath(Path.IsPathRooted(target)
? target
: Path.Combine(sourceFolder, target));
}
private static string? ExtractWikiLinkTarget(string value)
{
if (!value.StartsWith("[[", StringComparison.Ordinal) ||
!value.EndsWith("]]", StringComparison.Ordinal))
{
return null;
}
var inner = value[2..^2];
var labelSeparator = inner.IndexOf('|', StringComparison.Ordinal);
return labelSeparator < 0 ? inner : inner[..labelSeparator];
}
private static bool PathsEqual(string left, string right)
{
return string.Equals(
Path.GetFullPath(left),
Path.GetFullPath(right),
StringComparison.OrdinalIgnoreCase);
}
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);
}
}
@@ -0,0 +1,83 @@
using System.Text;
using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Summary;
public interface IMeetingSummaryFailureWriter
{
Task<MeetingSummaryRunResult> WriteAsync(
MeetingSessionArtifacts artifacts,
Exception exception,
CancellationToken cancellationToken);
}
public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
{
private readonly MeetingAssistantOptions options;
private readonly IMeetingNoteStore meetingNoteStore;
public MeetingSummaryFailureWriter(
IOptions<MeetingAssistantOptions> options,
IMeetingNoteStore meetingNoteStore)
{
this.options = options.Value;
this.meetingNoteStore = meetingNoteStore;
}
public async Task<MeetingSummaryRunResult> WriteAsync(
MeetingSessionArtifacts artifacts,
Exception exception,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
var meetingNote = File.Exists(artifacts.MeetingNotePath)
? await meetingNoteStore.ReadAsync(artifacts.MeetingNotePath, cancellationToken)
: new MeetingNote("", new MeetingNoteFrontmatter(), "");
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
artifacts,
meetingNote,
MeetingArtifactFrontmatterRenderer.DefaultTitle(meetingNote, "Meeting Summary"),
artifacts.SummaryPath);
await File.WriteAllTextAsync(
artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(
frontmatter,
RenderFailureMarkdown(artifacts, exception)),
cancellationToken);
return new MeetingSummaryRunResult(
artifacts.SummaryPath,
"Summary generation failed. See the summary file for error details.",
Succeeded: false,
Error: exception.Message);
}
private string RenderFailureMarkdown(MeetingSessionArtifacts artifacts, Exception exception)
{
var builder = new StringBuilder();
builder.AppendLine("# Meeting Summary");
builder.AppendLine();
builder.AppendLine("## Summary Generation Failed");
builder.AppendLine();
builder.AppendLine($"Summary generation failed at {DateTimeOffset.Now:O}.");
builder.AppendLine();
builder.AppendLine(MeetingNoteActionLinks.CreateSummaryRetryLink(
options.Api.PublicBaseUrl,
artifacts.SummaryPath));
builder.AppendLine();
builder.AppendLine("## Error");
builder.AppendLine();
builder.AppendLine("```text");
builder.AppendLine(exception.ToString().Replace("```", "` ` `", StringComparison.Ordinal));
builder.AppendLine("```");
builder.AppendLine();
builder.AppendLine("## Meeting Artifacts");
builder.AppendLine();
builder.AppendLine($"- Meeting note: `{artifacts.MeetingNotePath}`");
builder.AppendLine($"- Transcript: `{artifacts.TranscriptPath}`");
builder.AppendLine($"- Assistant context: `{artifacts.AssistantContextPath}`");
builder.AppendLine($"- Summary: `{artifacts.SummaryPath}`");
return builder.ToString();
}
}
@@ -0,0 +1,641 @@
using MeetingAssistant.MeetingNotes;
using System.Diagnostics;
using System.Text.Json;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary;
public sealed class MeetingSummaryTools
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly MeetingSessionArtifacts artifacts;
private readonly MeetingAssistantOptions options;
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
: this(artifacts, new MeetingAssistantOptions())
{
}
public MeetingSummaryTools(MeetingSessionArtifacts artifacts, MeetingAssistantOptions options)
{
this.artifacts = artifacts;
this.options = options;
}
public Task<string> ReadTranscript(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to);
}
public Task<string> ReadMeetingNote(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to);
}
public Task<string> ReadContext(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to);
}
public async Task<string> ReadUserNotes(int? @from = null, int? to = null)
{
if (!File.Exists(artifacts.MeetingNotePath))
{
return "";
}
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);
}
public Task<string> ReadGlossary(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault.DictationWordsPath), @from, to);
}
public async Task<string> WriteSummary(string markdown, string? title = null)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
var meetingNote = await ReadMeetingNoteAsync();
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
? title
: meetingNote.Frontmatter.Title;
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
artifacts,
meetingNote,
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
artifacts.SummaryPath);
await File.WriteAllTextAsync(
artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
return artifacts.SummaryPath;
}
public async Task<string> WriteContext(
string content,
int? @from = null,
int? to = null,
int? insert = null)
{
var editMode = FileLineEditMode.Create(@from, to, insert);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
}
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var existing = File.Exists(artifacts.AssistantContextPath)
? await File.ReadAllTextAsync(artifacts.AssistantContextPath)
: "";
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
var updatedBody = ApplyLineEdit(body, content, editMode);
var updated = string.IsNullOrWhiteSpace(frontmatter)
? updatedBody
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
await File.WriteAllTextAsync(artifacts.AssistantContextPath, updated);
return artifacts.AssistantContextPath;
}
public async Task<string> ListProjects()
{
var projects = await GetBoundProjectsAsync();
return string.Join('\n', projects.Select(project => project.Name));
}
public async Task<string> ListProjectFiles(string project)
{
var projectRoot = await ResolveBoundProjectRootAsync(project);
if (projectRoot is null)
{
return "";
}
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
.Select(path => 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)
{
var filePath = await ResolveBoundProjectFilePathAsync(project, path);
if (filePath is null || !File.Exists(filePath))
{
return "";
}
return ReadLines(await File.ReadAllTextAsync(filePath), @from, to);
}
public async Task<string> WriteProjectFile(
string project,
string path,
string content,
int? @from = null,
int? to = null,
int? insert = null)
{
var editMode = FileLineEditMode.Create(@from, to, insert);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
}
var target = ResolveExistingProjectFilePath(project, path);
if (target is null)
{
return "Refused: project does not exist or the path escapes the project folder.";
}
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
await WriteProjectFileContentAsync(target.Path, content, editMode);
return $"{target.Project.Name}/{ToToolPath(path)}";
}
public async Task<string> Search(string keywords, string[]? projects = null)
{
if (string.IsNullOrWhiteSpace(keywords))
{
return "";
}
var selectedProjects = await GetSearchProjectsAsync(projects);
if (selectedProjects.Count == 0)
{
return "";
}
var projectsRoot = GetProjectsRoot();
var result = await RunRipgrepAsync(projectsRoot, selectedProjects, keywords);
return result is not null
? FormatRipgrepJson(result, selectedProjects.Count == 1)
: SearchWithRegexFallback(selectedProjects, keywords, selectedProjects.Count == 1);
}
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null)
{
if (!File.Exists(path))
{
return "";
}
var content = await File.ReadAllTextAsync(path);
return ReadLines(content, from, to);
}
private async Task<MeetingNote> ReadMeetingNoteAsync()
{
if (!File.Exists(artifacts.MeetingNotePath))
{
return new MeetingNote("", new MeetingNoteFrontmatter(), "");
}
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var document = MarkdownDocumentParser.SplitOptional(content);
var frontmatter = new MeetingNoteFrontmatter();
if (document.HasFrontmatter)
{
var note = YamlDeserializer.Deserialize<MeetingNoteFrontmatterYaml>(document.Frontmatter) ?? new MeetingNoteFrontmatterYaml();
frontmatter.Title = note.Title ?? "";
frontmatter.StartTime = ParseDateTime(note.StartTime);
frontmatter.EndTime = ParseDateTime(note.EndTime);
frontmatter.Attendees = note.Attendees ?? [];
frontmatter.Projects = note.Projects ?? [];
frontmatter.Transcript = note.Transcript ?? "";
frontmatter.AssistantContext = note.AssistantContext ?? "";
frontmatter.Summary = note.Summary ?? "";
}
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
}
private async Task<List<ProjectFolder>> GetSearchProjectsAsync(string[]? projects)
{
var boundProjects = await GetBoundProjectsAsync();
if (projects is null || projects.Length == 0)
{
return boundProjects;
}
var requested = projects
.Where(project => !string.IsNullOrWhiteSpace(project))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return boundProjects
.Where(project => requested.Contains(project.Name))
.ToList();
}
private async Task<ProjectFolder?> ResolveBoundProjectAsync(string project)
{
if (string.IsNullOrWhiteSpace(project))
{
return null;
}
return (await GetBoundProjectsAsync())
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
}
private async Task<string?> ResolveBoundProjectRootAsync(string project)
{
return (await ResolveBoundProjectAsync(project))?.Path;
}
private async Task<string?> ResolveBoundProjectFilePathAsync(string project, string path)
{
var projectRoot = await ResolveBoundProjectRootAsync(project);
if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
{
return null;
}
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
return IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
}
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
{
if (string.IsNullOrWhiteSpace(project) ||
string.IsNullOrWhiteSpace(path) ||
Path.IsPathRooted(path))
{
return null;
}
var projectsRoot = GetProjectsRoot();
if (!Directory.Exists(projectsRoot))
{
return null;
}
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
.Select(candidate => new ProjectFolder(Path.GetFileName(candidate), candidate))
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
if (projectFolder is null)
{
return null;
}
var fullPath = Path.GetFullPath(Path.Combine(projectFolder.Path, path));
return 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;
}
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;
}
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 async Task<List<ProjectFolder>> GetBoundProjectsAsync()
{
var projectsRoot = GetProjectsRoot();
if (!Directory.Exists(projectsRoot))
{
return [];
}
var projectNames = await ReadMeetingProjectNamesAsync();
if (projectNames.Count == 0)
{
return [];
}
return Directory.EnumerateDirectories(projectsRoot)
.Select(path => new ProjectFolder(Path.GetFileName(path), path))
.Where(project => projectNames.Contains(project.Name))
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private async Task<HashSet<string>> ReadMeetingProjectNamesAsync()
{
if (!File.Exists(artifacts.MeetingNotePath))
{
return [];
}
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var document = MarkdownDocumentParser.SplitOptional(content);
if (!document.HasFrontmatter)
{
return [];
}
var note = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter) ?? new ProjectFrontmatter();
return (note.Projects ?? [])
.Where(project => !string.IsNullOrWhiteSpace(project))
.Select(project => project.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private string GetProjectsRoot()
{
return VaultPath.Resolve(options.Vault.ProjectsFolder);
}
private static async Task<string?> RunRipgrepAsync(
string projectsRoot,
IReadOnlyList<ProjectFolder> projects,
string keywords)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "rg",
WorkingDirectory = projectsRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add("--json");
startInfo.ArgumentList.Add("--line-number");
startInfo.ArgumentList.Add("--color");
startInfo.ArgumentList.Add("never");
startInfo.ArgumentList.Add("--");
startInfo.ArgumentList.Add(keywords);
foreach (var project in projects)
{
startInfo.ArgumentList.Add(project.Name);
}
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return process.ExitCode is 0 or 1 ? output : null;
}
catch
{
return null;
}
}
private static string FormatRipgrepJson(string output, bool singleProject)
{
var matches = new List<string>();
using var reader = new StringReader(output);
string? line;
while ((line = reader.ReadLine()) is not null)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var type) ||
type.GetString() != "match" ||
!root.TryGetProperty("data", out var data))
{
continue;
}
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
var lineNumber = data.GetProperty("line_number").GetInt32();
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
matches.Add($"{FormatSearchPath(path, singleProject)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
}
return string.Join('\n', matches);
}
private static string SearchWithRegexFallback(IReadOnlyList<ProjectFolder> projects, string keywords, bool singleProject)
{
try
{
var regex = new System.Text.RegularExpressions.Regex(keywords);
var matches = new List<string>();
foreach (var project in projects)
{
foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories))
{
var lines = File.ReadAllLines(file);
for (var index = 0; index < lines.Length; index++)
{
if (regex.IsMatch(lines[index]))
{
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
matches.Add($"{FormatSearchPath(relative, singleProject)}:{index + 1} {lines[index]}");
}
}
}
}
return string.Join('\n', matches);
}
catch
{
return "";
}
}
private static string FormatSearchPath(string path, bool singleProject)
{
var formatted = ToToolPath(path);
if (!singleProject)
{
return formatted;
}
var slashIndex = formatted.IndexOf('/', StringComparison.Ordinal);
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 sealed record ProjectFolder(string Name, string Path);
private sealed record ProjectFileTarget(ProjectFolder Project, string Path);
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)
{
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(FileLineEditKind.Overwrite);
}
}
private enum FileLineEditKind
{
Overwrite,
Replace,
Insert
}
private sealed class ProjectFrontmatter
{
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
}
private sealed class MeetingNoteFrontmatterYaml
{
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
public string? Title { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "start_time")]
public string? StartTime { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "end_time")]
public string? EndTime { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "attendees")]
public List<string>? Attendees { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "transcript")]
public string? Transcript { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "assistant_context")]
public string? AssistantContext { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "summary")]
public string? Summary { get; set; }
}
private static DateTimeOffset? ParseDateTime(string? value)
{
return DateTimeOffset.TryParse(value, out var parsed)
? parsed
: null;
}
}
@@ -0,0 +1,296 @@
using MeetingAssistant.MeetingNotes;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Summary;
#pragma warning disable MAAI001
public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
{
private const string Instructions = """
You are the Meeting Assistant summary agent.
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.
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. 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.
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
Keep the output grounded in the source material and explicitly say when a section has no known items.
""";
private readonly MeetingAssistantOptions options;
private readonly ILoggerFactory loggerFactory;
private readonly ILogger<OpenAiMeetingSummaryAgentPipeline> logger;
private readonly IServiceProvider services;
private readonly IMeetingSummaryFailureWriter failureWriter;
public OpenAiMeetingSummaryAgentPipeline(
IOptions<MeetingAssistantOptions> options,
ILoggerFactory loggerFactory,
ILogger<OpenAiMeetingSummaryAgentPipeline> logger,
IServiceProvider services,
IMeetingSummaryFailureWriter failureWriter)
{
this.options = options.Value;
this.loggerFactory = loggerFactory;
this.logger = logger;
this.services = services;
this.failureWriter = failureWriter;
}
public async Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
var agentOptions = options.Agent;
var key = ResolveApiKey(agentOptions);
var tools = CreateTools(new MeetingSummaryTools(artifacts, options));
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
key,
agentOptions.Model,
agentOptions.EnableThinking,
ToReasoningEffortValue(agentOptions.ReasoningEffort),
agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay,
compactionOptions: null,
logger);
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
using var chatClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
key,
agentOptions.Model,
agentOptions.EnableThinking,
ToReasoningEffortValue(agentOptions.ReasoningEffort),
agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay,
compactionOptions,
logger);
var agent = chatClient
.AsBuilder()
.UseFunctionInvocation()
.Build()
.AsAIAgent(
Instructions,
"meeting_summary",
"Writes the markdown summary note for a captured meeting.",
tools,
loggerFactory: loggerFactory,
services: services);
try
{
var response = await agent.RunAsync(
"Write the summary for this meeting. Use the tools; do not invent missing information.",
session: null,
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
cancellationToken: cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Meeting summary generation failed for {SummaryPath}; writing failure summary",
artifacts.SummaryPath);
return await failureWriter.WriteAsync(artifacts, exception, cancellationToken);
}
}
private static List<AITool> CreateTools(MeetingSummaryTools tools)
{
return
[
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."),
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."),
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."),
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."),
AIFunctionFactory.Create(
tools.WriteContext,
"write_context",
"Write internal assistant notes to the assistant context body. With no line arguments, overwrite the body. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
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."),
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."),
AIFunctionFactory.Create(
tools.ListProjects,
"list_projects",
"List the configured project folders bound to the current meeting note frontmatter."),
AIFunctionFactory.Create(
tools.ListProjectFiles,
"list_projectfiles",
"List markdown and other files in a bound project. The project parameter is a project folder name."),
AIFunctionFactory.Create(
tools.ReadProjectFile,
"read_projectfile",
"Read a bound project file, optionally clamping to from and to inclusive line numbers."),
AIFunctionFactory.Create(
tools.WriteProjectFile,
"write_projectfile",
"Write a file inside an existing project folder. With no line arguments, overwrite or create the file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
AIFunctionFactory.Create(
tools.Search,
"search",
"Run ripgrep syntax keywords against the requested bound projects, or all projects bound to the meeting note.")
];
}
private static ChatOptions CreateChatOptions(AgentOptions options)
{
var chatOptions = new ChatOptions
{
ModelId = options.Model,
AllowMultipleToolCalls = true,
ToolMode = ChatToolMode.Auto
};
if (options.EnableThinking)
{
chatOptions.Reasoning = new ReasoningOptions
{
Effort = ToReasoningEffort(options.ReasoningEffort)
};
}
return chatOptions;
}
private static LiteLlmResponsesCompactionOptions CreateCompactionOptions(
AgentOptions options,
IChatClient? summaryClient)
{
return new LiteLlmResponsesCompactionOptions
{
Enabled = options.EnableCompaction,
ContextWindowTokens = options.ContextWindowTokens,
MaxOutputTokens = options.MaxOutputTokens,
RemainingRatio = options.CompactionRemainingRatio,
CompactPath = options.ResponsesCompactPath,
FallbackStrategy = CreateFallbackCompactionStrategy(
options.ContextWindowTokens,
options.MaxOutputTokens,
options.CompactionRemainingRatio,
summaryClient)
};
}
internal static CompactionStrategy CreateFallbackCompactionStrategyForTests(
int contextWindowTokens,
int maxOutputTokens,
double remainingRatio,
IChatClient? summaryClient)
{
return CreateFallbackCompactionStrategy(
contextWindowTokens,
maxOutputTokens,
remainingRatio,
summaryClient);
}
private static CompactionStrategy CreateFallbackCompactionStrategy(
int contextWindowTokens,
int maxOutputTokens,
double remainingRatio,
IChatClient? summaryClient)
{
var contextWindow = Math.Max(1, contextWindowTokens);
var outputReserve = Math.Clamp(maxOutputTokens, 0, contextWindow - 1);
var inputBudget = contextWindow - outputReserve;
var triggerTokens = Math.Max(1, (int)Math.Floor(inputBudget * (1 - Math.Clamp(remainingRatio, 0.01, 0.90))));
var target = CompactionTriggers.TokensBelow(Math.Max(1, triggerTokens - 1));
var trigger = CompactionTriggers.TokensExceed(triggerTokens);
var strategies = new List<CompactionStrategy>
{
new ToolResultCompactionStrategy(trigger, minimumPreservedGroups: 4, target)
};
if (summaryClient is not null)
{
strategies.Add(new SummarizationCompactionStrategy(
summaryClient,
trigger,
minimumPreservedGroups: 4,
target: target));
}
strategies.Add(new SlidingWindowCompactionStrategy(
trigger,
minimumPreservedTurns: 4,
target));
strategies.Add(new TruncationCompactionStrategy(
trigger,
minimumPreservedGroups: 1,
target));
return new PipelineCompactionStrategy(strategies);
}
private static string ToReasoningEffortValue(ReasoningEffortOption effort)
{
return effort switch
{
ReasoningEffortOption.None => "none",
ReasoningEffortOption.Low => "low",
ReasoningEffortOption.High => "high",
ReasoningEffortOption.ExtraHigh => "xhigh",
_ => "medium"
};
}
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
{
return effort switch
{
ReasoningEffortOption.None => ReasoningEffort.None,
ReasoningEffortOption.Low => ReasoningEffort.Low,
ReasoningEffortOption.High => ReasoningEffort.High,
ReasoningEffortOption.ExtraHigh => ReasoningEffort.ExtraHigh,
_ => ReasoningEffort.Medium
};
}
private static string ResolveApiKey(AgentOptions options)
{
if (!string.IsNullOrWhiteSpace(options.Key))
{
return options.Key;
}
var value = Environment.GetEnvironmentVariable(options.KeyEnv);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
throw new InvalidOperationException(
$"No agent API key configured. Set MeetingAssistant:Agent:Key or environment variable '{options.KeyEnv}'.");
}
}
#pragma warning restore MAAI001