feat(macos): add native Meeting Assistant support #39

Open
Daniel wants to merge 13 commits from Daniel/meeting-assistant:codex/macos-support into main
20 changed files with 981 additions and 11 deletions
Showing only changes of commit 9542c05ea4 - Show all commits
@@ -0,0 +1,89 @@
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.MacOs;
using MeetingAssistant.Workflow;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace MeetingAssistant.Tests;
public sealed class MacOsDesktopControlManifestTests
{
[Fact]
public void MacOsApplicationRegistersARealInteractiveAgentWindow()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
using var factory = new WebApplicationFactory<Program>();
var service = factory.Services.GetRequiredService<IWorkflowRulesEditorWindowService>();
Assert.IsType<MacOsWorkflowRulesEditorWindowService>(service);
}
[Fact]
public void ManifestPreservesConfiguredWindowsEquivalentShortcutsAndControlEndpoints()
{
var profiles = new[]
{
Profile("default", "Ctrl+Alt+M", "Ctrl+Alt+Z", "Ctrl+Alt+S"),
Profile("english", "Ctrl+Alt+L", "", "")
};
var hotkeys = new[]
{
new LaunchProfileHotkey("default", "Ctrl+Alt+M", LaunchProfileHotkeyAction.ToggleRecording),
new LaunchProfileHotkey("default", "Ctrl+Alt+Z", LaunchProfileHotkeyAction.AbortRecording),
new LaunchProfileHotkey("default", "Ctrl+Alt+S", LaunchProfileHotkeyAction.CaptureScreenshot),
new LaunchProfileHotkey("english", "Ctrl+Alt+L", LaunchProfileHotkeyAction.ToggleRecording)
};
var manifest = MacOsDesktopControlManifest.Create(
"http://127.0.0.1:5090",
profiles,
hotkeys);
Assert.Collection(
manifest.Hotkeys,
hotkey =>
{
Assert.Equal("Ctrl+Alt+M", hotkey.Shortcut);
Assert.Equal("/profiles/default/recording/toggle", hotkey.Endpoint);
},
hotkey =>
{
Assert.Equal("Ctrl+Alt+Z", hotkey.Shortcut);
Assert.Equal("/profiles/default/recording/abort", hotkey.Endpoint);
},
hotkey =>
{
Assert.Equal("Ctrl+Alt+S", hotkey.Shortcut);
Assert.Equal("/profiles/default/meetings/screenshot/capture", hotkey.Endpoint);
},
hotkey =>
{
Assert.Equal("Ctrl+Alt+L", hotkey.Shortcut);
Assert.Equal("/profiles/english/recording/toggle", hotkey.Endpoint);
});
Assert.Equal("http://127.0.0.1:5090/recording/status", manifest.StatusUrl);
Assert.Equal("http://127.0.0.1:5090/diagnostics/settings-and-logs", manifest.OpenAgentUrl);
}
private static LaunchProfile Profile(string name, string toggle, string abort, string screenshot)
{
return new LaunchProfile(name, new MeetingAssistantOptions
{
Hotkey = new HotkeyOptions
{
Toggle = toggle,
Abort = abort
},
Screenshots = new ScreenshotOptions
{
Hotkey = screenshot
}
});
}
}
@@ -0,0 +1,47 @@
using MeetingAssistant.LaunchProfiles;
namespace MeetingAssistant.MacOs;
public sealed record MacOsDesktopControlManifest(
string BaseUrl,
string StatusUrl,
string OpenAgentUrl,
IReadOnlyList<MacOsDesktopProfile> Profiles,
IReadOnlyList<MacOsDesktopHotkey> Hotkeys)
{
public static MacOsDesktopControlManifest Create(
string baseUrl,
IReadOnlyList<LaunchProfile> profiles,
IReadOnlyList<LaunchProfileHotkey> hotkeys)
{
var normalizedBaseUrl = baseUrl.TrimEnd('/');
return new MacOsDesktopControlManifest(
normalizedBaseUrl,
$"{normalizedBaseUrl}/recording/status",
$"{normalizedBaseUrl}/diagnostics/settings-and-logs",
profiles
.Select(profile => new MacOsDesktopProfile(
profile.Name,
profile.Options.Hotkey.Toggle))
.ToArray(),
hotkeys.Select(CreateHotkey).ToArray());
}
private static MacOsDesktopHotkey CreateHotkey(LaunchProfileHotkey hotkey)
{
var endpoint = hotkey.Action switch
{
LaunchProfileHotkeyAction.ToggleRecording => "recording/toggle",
LaunchProfileHotkeyAction.AbortRecording => "recording/abort",
LaunchProfileHotkeyAction.CaptureScreenshot => "meetings/screenshot/capture",
_ => throw new ArgumentOutOfRangeException(nameof(hotkey))
};
return new MacOsDesktopHotkey(
hotkey.Hotkey,
$"/profiles/{Uri.EscapeDataString(hotkey.ProfileName)}/{endpoint}");
}
}
public sealed record MacOsDesktopProfile(string Name, string ToggleShortcut);
public sealed record MacOsDesktopHotkey(string Shortcut, string Endpoint);
@@ -0,0 +1,107 @@
using System.Diagnostics;
using System.Text.Json;
using MeetingAssistant.LaunchProfiles;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.MacOs;
public sealed class MacOsDesktopControlService : IHostedService, IDisposable
{
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly IOptions<MeetingAssistantOptions> options;
private readonly IHostApplicationLifetime applicationLifetime;
private readonly ILogger<MacOsDesktopControlService> logger;
private Process? helper;
private string? manifestPath;
public MacOsDesktopControlService(
ILaunchProfileOptionsProvider launchProfiles,
IOptions<MeetingAssistantOptions> options,
IHostApplicationLifetime applicationLifetime,
ILogger<MacOsDesktopControlService> logger)
{
this.launchProfiles = launchProfiles;
this.options = options;
this.applicationLifetime = applicationLifetime;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsMacOS())
{
return Task.CompletedTask;
}
var helperPath = Path.Combine(AppContext.BaseDirectory, "Native", "macos-desktop-controls");
if (!File.Exists(helperPath))
{
logger.LogWarning("macOS desktop controls helper was not found at {HelperPath}", helperPath);
return Task.CompletedTask;
}
var manifest = MacOsDesktopControlManifest.Create(
options.Value.Api.PublicBaseUrl,
launchProfiles.GetProfiles(),
launchProfiles.GetHotkeys());
manifestPath = Path.Combine(
Path.GetTempPath(),
$"meeting-assistant-desktop-{Environment.ProcessId}.json");
File.WriteAllText(
manifestPath,
JsonSerializer.Serialize(
manifest,
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
helper = Process.Start(new ProcessStartInfo
{
FileName = helperPath,
ArgumentList = { manifestPath },
UseShellExecute = false,
CreateNoWindow = true
});
if (helper is null)
{
throw new InvalidOperationException("Could not start the macOS desktop controls helper.");
}
helper.EnableRaisingEvents = true;
helper.Exited += (_, _) =>
{
if (!applicationLifetime.ApplicationStopping.IsCancellationRequested)
{
logger.LogWarning("macOS desktop controls helper exited unexpectedly");
}
};
logger.LogInformation("Started macOS menu-bar controls with {HotkeyCount} global hotkeys", manifest.Hotkeys.Count);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
if (helper is { HasExited: false })
{
helper.Kill();
helper.WaitForExit(TimeSpan.FromSeconds(2));
}
DeleteManifest();
return Task.CompletedTask;
}
public void Dispose()
{
helper?.Dispose();
DeleteManifest();
}
private void DeleteManifest()
{
if (manifestPath is not null && File.Exists(manifestPath))
{
File.Delete(manifestPath);
manifestPath = null;
}
}
}
@@ -0,0 +1,161 @@
using System.Diagnostics;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.MacOs;
public sealed class MacOsWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
{
private readonly IOptions<MeetingAssistantOptions> options;
public MacOsWorkflowRulesEditorWindowService(IOptions<MeetingAssistantOptions> options)
{
this.options = options;
}
public void Show()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
var url = $"{options.Value.Api.PublicBaseUrl.TrimEnd('/')}/diagnostics/settings-and-logs";
Process.Start(new ProcessStartInfo
{
FileName = "/usr/bin/open",
ArgumentList = { url },
UseShellExecute = false
});
}
}
public sealed class MacOsWorkflowRulesEditorSession
{
private readonly WorkflowRulesEditorChatViewModel viewModel;
private readonly SemaphoreSlim sendLock = new(1, 1);
public MacOsWorkflowRulesEditorSession(WorkflowRulesEditorChatViewModel viewModel)
{
this.viewModel = viewModel;
}
public async Task<MacOsWorkflowRulesEditorChatResponse> SendAsync(
string? message,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(message))
{
return Snapshot();
}
await sendLock.WaitAsync(cancellationToken);
try
{
viewModel.Draft = message;
await viewModel.SendAsync(cancellationToken);
return Snapshot();
}
finally
{
sendLock.Release();
}
}
private MacOsWorkflowRulesEditorChatResponse Snapshot()
{
return new MacOsWorkflowRulesEditorChatResponse(
viewModel.Messages
.Where(item => item.Kind == WorkflowRulesEditorConversationItemKind.Message)
.Select(item => new MacOsWorkflowRulesEditorMessage(
item.Message!.Role.ToString().ToLowerInvariant(),
item.Message.Content))
.ToArray());
}
}
public sealed record MacOsWorkflowRulesEditorChatRequest(string? Message);
public sealed record MacOsWorkflowRulesEditorChatResponse(
IReadOnlyList<MacOsWorkflowRulesEditorMessage> Messages);
public sealed record MacOsWorkflowRulesEditorMessage(string Role, string Content);
public static class MacOsWorkflowRulesEditorPage
{
public const string Html = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Meeting Summary Agent</title>
<style>
:root { color-scheme: dark; font: 15px -apple-system, BlinkMacSystemFont, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; background: #171719; color: #f5f5f7; }
main { height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 18px; gap: 14px; }
h1 { margin: 0; font-size: 18px; }
#messages { overflow: auto; display: flex; flex-direction: column; gap: 10px; }
.message { max-width: 82%; padding: 10px 12px; border: 1px solid #414147; border-radius: 10px; white-space: pre-wrap; }
.user { align-self: flex-end; background: #293650; }
.agent { align-self: flex-start; background: #232326; }
form { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { min-height: 78px; resize: vertical; border-radius: 9px; border: 1px solid #55555d; background: #222225; color: inherit; padding: 10px; font: inherit; }
button { min-width: 96px; border: 0; border-radius: 9px; background: #0a84ff; color: white; font-weight: 600; }
button:disabled { opacity: .55; }
</style>
</head>
<body>
<main>
<h1>Meeting Summary Agent</h1>
<section id="messages"></section>
<form id="chat">
<textarea id="prompt" placeholder="ask me what I can do for you"></textarea>
<button id="send">Send</button>
</form>
</main>
<script>
const form = document.querySelector('#chat');
const prompt = document.querySelector('#prompt');
const send = document.querySelector('#send');
const messages = document.querySelector('#messages');
const render = items => {
messages.replaceChildren(...items.map(item => {
const card = document.createElement('div');
card.className = `message ${item.role}`;
card.textContent = item.content;
return card;
}));
messages.scrollTop = messages.scrollHeight;
};
form.addEventListener('submit', async event => {
event.preventDefault();
const message = prompt.value.trim();
if (!message) return;
prompt.value = '';
send.disabled = true;
try {
const response = await fetch('/diagnostics/settings-and-logs/chat', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({message})
});
const body = await response.json();
render(body.messages);
} finally {
send.disabled = false;
prompt.focus();
}
});
prompt.addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
</script>
</body>
</html>
""";
}
+9 -5
View File
@@ -16,13 +16,15 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('OSX')) and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'"> <PropertyGroup Condition="$([MSBuild]::IsOSPlatform('OSX')) and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<MacOsAudioCaptureEnabled>true</MacOsAudioCaptureEnabled> <MacOsNativeHelpersEnabled>true</MacOsNativeHelpersEnabled>
<MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource> <MacOsAudioCaptureSource>$(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift</MacOsAudioCaptureSource>
<MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath> <MacOsAudioCaptureOutputPath>Native/macos-meeting-audio-capture</MacOsAudioCaptureOutputPath>
<MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid> <MacOsAudioCaptureRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</MacOsAudioCaptureRid>
<MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid> <MacOsAudioCaptureRid Condition="'$(MacOsAudioCaptureRid)' == ''">$(NETCoreSdkRuntimeIdentifier)</MacOsAudioCaptureRid>
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture> <MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureRid)' == 'osx-x64'">x86_64</MacOsAudioCaptureArchitecture>
<MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureArchitecture)' == ''">arm64</MacOsAudioCaptureArchitecture> <MacOsAudioCaptureArchitecture Condition="'$(MacOsAudioCaptureArchitecture)' == ''">arm64</MacOsAudioCaptureArchitecture>
<MacOsDesktopControlsSource>$(MSBuildProjectDirectory)/Native/MacOsDesktopControls/main.swift</MacOsDesktopControlsSource>
<MacOsDesktopControlsOutputPath>Native/macos-desktop-controls</MacOsDesktopControlsOutputPath>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -72,19 +74,21 @@
</Target> </Target>
<Target <Target
Name="BuildMacOsAudioCaptureHelper" Name="BuildMacOsNativeHelpers"
AfterTargets="Build" AfterTargets="Build"
Condition="'$(MacOsAudioCaptureEnabled)' == 'true'"> Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
<MakeDir Directories="$(TargetDir)Native" /> <MakeDir Directories="$(TargetDir)Native" />
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit &quot;$(MacOsAudioCaptureSource)&quot; -o &quot;$(TargetDir)$(MacOsAudioCaptureOutputPath)&quot;" /> <Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AVFoundation -framework CoreMedia -framework ScreenCaptureKit &quot;$(MacOsAudioCaptureSource)&quot; -o &quot;$(TargetDir)$(MacOsAudioCaptureOutputPath)&quot;" />
<Exec Command="/usr/bin/xcrun swiftc -parse-as-library -O -target $(MacOsAudioCaptureArchitecture)-apple-macos13.0 -framework AppKit -framework Carbon -framework WebKit &quot;$(MacOsDesktopControlsSource)&quot; -o &quot;$(TargetDir)$(MacOsDesktopControlsOutputPath)&quot;" />
</Target> </Target>
<Target <Target
Name="CopyMacOsAudioCaptureHelperToPublish" Name="CopyMacOsNativeHelpersToPublish"
AfterTargets="Publish" AfterTargets="Publish"
Condition="'$(MacOsAudioCaptureEnabled)' == 'true'"> Condition="'$(MacOsNativeHelpersEnabled)' == 'true'">
<MakeDir Directories="$(PublishDir)Native" /> <MakeDir Directories="$(PublishDir)Native" />
<Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" /> <Copy SourceFiles="$(TargetDir)$(MacOsAudioCaptureOutputPath)" DestinationFolder="$(PublishDir)Native" />
<Copy SourceFiles="$(TargetDir)$(MacOsDesktopControlsOutputPath)" DestinationFolder="$(PublishDir)Native" />
</Target> </Target>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'"> <ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
@@ -0,0 +1,289 @@
import AppKit
import Carbon
import Foundation
import WebKit
struct Manifest: Decodable {
let baseUrl: String
let statusUrl: String
let openAgentUrl: String
let profiles: [Profile]
let hotkeys: [Hotkey]
}
struct Profile: Decodable {
let name: String
let toggleShortcut: String
}
struct Hotkey: Decodable {
let shortcut: String
let endpoint: String
}
struct RecordingStatus: Decodable {
let isRecording: Bool
let state: String
let launchProfile: String?
}
@main
final class DesktopControls: NSObject, NSApplicationDelegate, NSMenuDelegate {
private let manifest: Manifest
private var status = RecordingStatus(isRecording: false, state: "Idle", launchProfile: nil)
private var statusItem: NSStatusItem!
private var registeredHotkeys: [UInt32: Hotkey] = [:]
private var timer: Timer?
private var agentWindow: NSWindow?
override init() {
guard CommandLine.arguments.count == 2,
let data = try? Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1])),
let decoded = try? JSONDecoder().decode(Manifest.self, from: data) else {
fputs("Usage: macos-desktop-controls <manifest.json>\n", stderr)
exit(2)
}
manifest = decoded
super.init()
}
static func main() {
let application = NSApplication.shared
let delegate = DesktopControls()
application.delegate = delegate
application.setActivationPolicy(.accessory)
application.run()
}
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
statusItem.button?.image = NSImage(
systemSymbolName: "waveform.circle",
accessibilityDescription: "Meeting Assistant")
let menu = NSMenu()
menu.delegate = self
statusItem.menu = menu
registerHotkeys()
refreshStatus()
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
self?.refreshStatus()
}
}
func applicationWillTerminate(_ notification: Notification) {
timer?.invalidate()
}
func menuWillOpen(_ menu: NSMenu) {
rebuildMenu(menu)
refreshStatus()
}
private func rebuildMenu(_ menu: NSMenu) {
menu.removeAllItems()
let stateItem = NSMenuItem(title: tooltip, action: nil, keyEquivalent: "")
stateItem.isEnabled = false
menu.addItem(stateItem)
menu.addItem(.separator())
menu.addItem(item("Open agent", #selector(openAgent)))
if status.isRecording {
menu.addItem(item("Stop meeting recording and transcribe", #selector(stopRecording)))
menu.addItem(item("Cancel meeting recording and discard", #selector(abortRecording)))
} else {
for (index, profile) in manifest.profiles.enumerated() {
let entry = item(
"Start meeting recording (\(profile.name))",
#selector(startProfile(_:)))
entry.tag = index
entry.keyEquivalent = menuKey(from: profile.toggleShortcut)
entry.keyEquivalentModifierMask = menuModifiers(from: profile.toggleShortcut)
menu.addItem(entry)
}
}
menu.addItem(.separator())
menu.addItem(item("Exit", #selector(exitApplication)))
}
private var tooltip: String {
if status.isRecording {
return status.launchProfile.map { "Meeting Assistant — recording (\($0))" }
?? "Meeting Assistant — recording"
}
return status.state.lowercased() == "summarizing"
? "Meeting Assistant — summarizing"
: "Meeting Assistant — idle"
}
private func item(_ title: String, _ action: Selector) -> NSMenuItem {
let result = NSMenuItem(title: title, action: action, keyEquivalent: "")
result.target = self
return result
}
@objc private func openAgent() {
if agentWindow == nil {
let webView = WKWebView(frame: .zero)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 840, height: 760),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
window.title = "Meeting Summary Agent"
window.contentView = webView
window.center()
window.isReleasedWhenClosed = false
webView.load(URLRequest(url: URL(string: manifest.openAgentUrl)!))
agentWindow = window
}
NSApplication.shared.activate(ignoringOtherApps: true)
agentWindow?.makeKeyAndOrderFront(nil)
}
@objc private func startProfile(_ sender: NSMenuItem) {
let profile = manifest.profiles[sender.tag]
post(path: "/profiles/\(escaped(profile.name))/recording/start")
}
@objc private func stopRecording() {
post(path: "/recording/stop")
}
@objc private func abortRecording() {
post(path: "/recording/abort")
}
@objc private func exitApplication() {
post(path: "/application/exit")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NSApplication.shared.terminate(nil)
}
}
private func refreshStatus() {
guard let url = URL(string: manifest.statusUrl) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let data,
let next = try? JSONDecoder().decode(RecordingStatus.self, from: data) else { return }
DispatchQueue.main.async {
self?.status = next
self?.statusItem.button?.image = NSImage(
systemSymbolName: next.isRecording ? "record.circle.fill" : "waveform.circle",
accessibilityDescription: self?.tooltip)
}
}.resume()
}
private func post(path: String) {
post(url: manifest.baseUrl + path)
}
private func post(url value: String) {
guard let url = URL(string: value) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
URLSession.shared.dataTask(with: request) { [weak self] _, _, _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self?.refreshStatus()
}
}.resume()
}
private func registerHotkeys() {
var eventType = EventTypeSpec(
eventClass: OSType(kEventClassKeyboard),
eventKind: OSType(kEventHotKeyPressed))
InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, context in
guard let event, let context else { return noErr }
var identifier = EventHotKeyID()
GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&identifier)
let owner = Unmanaged<DesktopControls>.fromOpaque(context).takeUnretainedValue()
if let hotkey = owner.registeredHotkeys[identifier.id] {
owner.post(path: hotkey.endpoint)
}
return noErr
},
1,
&eventType,
Unmanaged.passUnretained(self).toOpaque(),
nil)
for (index, hotkey) in manifest.hotkeys.enumerated() {
guard let keyCode = carbonKeyCode(from: hotkey.shortcut) else { continue }
let identifier = EventHotKeyID(signature: fourCC("MTGA"), id: UInt32(index + 1))
var reference: EventHotKeyRef?
let result = RegisterEventHotKey(
keyCode,
carbonModifiers(from: hotkey.shortcut),
identifier,
GetApplicationEventTarget(),
0,
&reference)
if result == noErr {
registeredHotkeys[identifier.id] = hotkey
}
}
}
private func escaped(_ value: String) -> String {
value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value
}
}
private func tokens(_ shortcut: String) -> [String] {
shortcut.uppercased().split(separator: "+").map(String.init)
}
private func carbonModifiers(from shortcut: String) -> UInt32 {
let parts = tokens(shortcut)
var result: UInt32 = 0
if parts.contains("CTRL") || parts.contains("CONTROL") { result |= UInt32(controlKey) }
if parts.contains("ALT") || parts.contains("OPTION") { result |= UInt32(optionKey) }
if parts.contains("SHIFT") { result |= UInt32(shiftKey) }
if parts.contains("WIN") || parts.contains("WINDOWS") || parts.contains("CMD") || parts.contains("COMMAND") {
result |= UInt32(cmdKey)
}
return result
}
private func menuModifiers(from shortcut: String) -> NSEvent.ModifierFlags {
let parts = tokens(shortcut)
var result: NSEvent.ModifierFlags = []
if parts.contains("CTRL") || parts.contains("CONTROL") { result.insert(.control) }
if parts.contains("ALT") || parts.contains("OPTION") { result.insert(.option) }
if parts.contains("SHIFT") { result.insert(.shift) }
if parts.contains("WIN") || parts.contains("WINDOWS") || parts.contains("CMD") || parts.contains("COMMAND") {
result.insert(.command)
}
return result
}
private func menuKey(from shortcut: String) -> String {
tokens(shortcut).last?.lowercased() ?? ""
}
private func carbonKeyCode(from shortcut: String) -> UInt32? {
guard let key = tokens(shortcut).last, key.count == 1, let scalar = key.unicodeScalars.first else {
return nil
}
let codes: [Character: UInt32] = [
"A": 0, "S": 1, "D": 2, "F": 3, "H": 4, "G": 5, "Z": 6, "X": 7,
"C": 8, "V": 9, "B": 11, "Q": 12, "W": 13, "E": 14, "R": 15,
"Y": 16, "T": 17, "M": 46, "L": 37, "N": 45, "P": 35, "O": 31,
"I": 34, "U": 32, "J": 38, "K": 40
]
return codes[Character(String(scalar))]
}
private func fourCC(_ value: String) -> OSType {
value.utf8.reduce(0) { ($0 << 8) + OSType($1) }
}
+23 -1
View File
@@ -3,6 +3,7 @@ using MeetingAssistant.Calendar;
using MeetingAssistant.Hotkeys; using MeetingAssistant.Hotkeys;
using MeetingAssistant.LaunchProfiles; using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Logging; using MeetingAssistant.Logging;
using MeetingAssistant.MacOs;
using MeetingAssistant.MeetingNotes; using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording; using MeetingAssistant.Recording;
using MeetingAssistant.Screenshots; using MeetingAssistant.Screenshots;
@@ -38,6 +39,7 @@ builder.Services.AddSingleton<IMeetingMetadataProvider, OutlookClassicMeetingMet
#else #else
PortableMeetingAudioRegistration.Add(builder.Services); PortableMeetingAudioRegistration.Add(builder.Services);
builder.Services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>(); builder.Services.AddSingleton<IMeetingMetadataProvider, NoopMeetingMetadataProvider>();
builder.Services.AddHostedService<MacOsDesktopControlService>();
#endif #endif
builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>(); builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>(); builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
@@ -111,7 +113,8 @@ builder.Services.AddSingleton(services => WorkflowRulesEditorMarkdownLinkResolve
services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value)); services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value));
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>(); builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, WpfWorkflowRulesEditorWindowService>();
#else #else
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>(); builder.Services.AddSingleton<MacOsWorkflowRulesEditorSession>();
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MacOsWorkflowRulesEditorWindowService>();
#endif #endif
builder.Services.AddSingleton<AsrDiagnosticService>(); builder.Services.AddSingleton<AsrDiagnosticService>();
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>(); builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
@@ -152,6 +155,11 @@ app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Res
app.MapGet("/profiles/{launchProfile}/recording/status", ( app.MapGet("/profiles/{launchProfile}/recording/status", (
string launchProfile, string launchProfile,
MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus)); MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
app.MapPost("/application/exit", (IHostApplicationLifetime lifetime) =>
{
lifetime.StopApplication();
return Results.Accepted();
});
app.MapGet("/diagnostics/outlook/current-meeting", async ( app.MapGet("/diagnostics/outlook/current-meeting", async (
IMeetingMetadataProvider metadataProvider, IMeetingMetadataProvider metadataProvider,
IOptions<MeetingAssistantOptions> options, IOptions<MeetingAssistantOptions> options,
@@ -281,6 +289,15 @@ app.MapPost("/diagnostics/settings-and-logs/show", (
rulesEditorWindow.Show(); rulesEditorWindow.Show();
return Results.Accepted(); return Results.Accepted();
}); });
#if !WINDOWS
app.MapGet("/diagnostics/settings-and-logs", () =>
Results.Content(MacOsWorkflowRulesEditorPage.Html, "text/html"));
app.MapPost("/diagnostics/settings-and-logs/chat", async (
MacOsWorkflowRulesEditorChatRequest request,
MacOsWorkflowRulesEditorSession session,
CancellationToken cancellationToken) =>
Results.Ok(await session.SendAsync(request.Message, cancellationToken)));
#endif
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration) static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
{ {
@@ -323,6 +340,11 @@ app.MapPost("/profiles/{launchProfile}/recording/abort", async (
MeetingRecordingCoordinator coordinator, MeetingRecordingCoordinator coordinator,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
Results.Ok(await coordinator.AbortAsync(cancellationToken))); Results.Ok(await coordinator.AbortAsync(cancellationToken)));
app.MapPost("/profiles/{launchProfile}/meetings/screenshot/capture", async (
string launchProfile,
MeetingRecordingCoordinator coordinator,
CancellationToken cancellationToken) =>
Results.Ok(await coordinator.CaptureScreenshotAsync(launchProfile, cancellationToken)));
app.MapPost("/meetings/current/summary/run", async ( app.MapPost("/meetings/current/summary/run", async (
MeetingRecordingCoordinator coordinator, MeetingRecordingCoordinator coordinator,
IMeetingSummaryPipeline summaryPipeline, IMeetingSummaryPipeline summaryPipeline,
+5 -5
View File
@@ -9,7 +9,7 @@ This repository owns the application source, tests, OpenSpec requirements, confi
The app is intentionally local-first: The app is intentionally local-first:
- Windows builds provide the tray icon, global hotkeys, NAudio capture, Outlook Classic COM enrichment, active-window screenshots, and notifications. - Windows builds provide the tray icon, global hotkeys, NAudio capture, Outlook Classic COM enrichment, active-window screenshots, and notifications.
- Portable builds on macOS capture the default microphone through AVFoundation and computer output through ScreenCaptureKit, while keeping Windows-only desktop integrations compiled out. - Portable builds on macOS provide a native menu-bar icon and global hotkeys, and capture the default microphone through AVFoundation plus computer output through ScreenCaptureKit.
- Portable builds on other hosts keep the server/testable service surface but do not provide meeting audio capture. - Portable builds on other hosts keep the server/testable service surface but do not provide meeting audio capture.
- The normal runtime endpoint is local HTTP on port `5090`, with `/health` and `/recording/status` as the safe first checks. - The normal runtime endpoint is local HTTP on port `5090`, with `/health` and `/recording/status` as the safe first checks.
- Behavior changes are OpenSpec-driven; current accepted requirements live under `openspec/specs`. - Behavior changes are OpenSpec-driven; current accepted requirements live under `openspec/specs`.
@@ -32,7 +32,7 @@ dotnet build MeetingAssistant/MeetingAssistant.csproj -f net10.0
dotnet run --project MeetingAssistant/MeetingAssistant.csproj -f net10.0 dotnet run --project MeetingAssistant/MeetingAssistant.csproj -f net10.0
``` ```
The macOS build compiles the bundled Swift capture helper into `Native/macos-meeting-audio-capture`. On first recording, allow both **Microphone** and **Screen & System Audio Recording** under System Settings > Privacy & Security. macOS recording is controlled through the local HTTP endpoints because the tray icon and global hotkeys remain Windows-only. The macOS build compiles bundled Swift helpers into `Native/macos-meeting-audio-capture` and `Native/macos-desktop-controls`. On first recording, allow both **Microphone** and **Screen & System Audio Recording** under System Settings > Privacy & Security. The menu-bar icon and global hotkeys use the same configured bindings as Windows and call the local service surface.
For the durable local background instance, use the snippets restart helper: For the durable local background instance, use the snippets restart helper:
@@ -58,7 +58,7 @@ Do not restart while a meeting is recording, transcribing, finalizing speaker re
## Runtime Behavior ## Runtime Behavior
Recording can be controlled through global hotkeys, the Windows tray icon, or local HTTP endpoints. The default hotkeys are: Recording can be controlled through global hotkeys, the Windows tray icon, the macOS menu-bar icon, or local HTTP endpoints. The default hotkeys are:
- `Ctrl+Alt+M`: toggle the default recording profile. - `Ctrl+Alt+M`: toggle the default recording profile.
- `Ctrl+Alt+L`: toggle or switch to the configured `english` launch profile. - `Ctrl+Alt+L`: toggle or switch to the configured `english` launch profile.
@@ -142,9 +142,9 @@ See `docs/meeting-assistant-configuration.md` for the full configuration referen
Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, transcript-line writes, and added attendees. They can add/remove attendees, set supported properties, add context, add projects, rewrite a transcript line, and transform an attendee name before it is stored. Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, transcript-line writes, and added attendees. They can add/remove attendees, set supported properties, add context, add projects, rewrite a transcript line, and transform an attendee name before it is stored.
The tray menu exposes `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools. The Windows tray and macOS menu-bar menus expose `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools. Windows renders the chat with WPF; macOS renders the same agent pipeline in a native AppKit window backed by WebKit.
At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture is available in the portable build; Windows-only tray, hotkey, Outlook, notification, and screenshot features remain unavailable there. At startup, Meeting Assistant detects whether the host is Windows or macOS and includes that immutable runtime context in the interactive settings/logs assistant instructions. On Windows the assistant uses Windows commands and concepts such as PowerShell, Windows paths, services, and Task Manager. On macOS it uses zsh, POSIX paths, launchd/LaunchAgents, and Activity Monitor. This guidance is also appended when a custom interactive-agent prompt is configured. macOS audio capture, menu-bar controls, and global hotkeys are available in the portable build; Outlook, Windows notifications, and active-window screenshot capture remain Windows-only.
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`. Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
@@ -0,0 +1,23 @@
## Context
The application already exposes local HTTP endpoints for recording lifecycle actions. Windows desktop integrations invoke the coordinator in-process, while macOS currently has no desktop process capable of participating in AppKit or Carbon event loops.
## Decisions
### Native helper owns macOS desktop APIs
A bundled Swift helper owns the AppKit status item and Carbon global-hotkey registrations. The .NET host starts the helper only on macOS and terminates it during shutdown.
### Existing HTTP endpoints remain the control seam
The helper invokes loopback endpoints for recording and diagnostic actions. It does not duplicate recording state or lifecycle policy. It polls the status endpoint to keep the menu-bar presentation current.
### Configuration is passed as an explicit manifest
The .NET host derives a manifest from configured launch profiles. Shortcut strings are passed unchanged so macOS uses the same bindings displayed and registered on Windows.
## Verification
- Behavior tests prove the manifest preserves configured shortcuts and maps actions to the existing endpoints.
- The Swift helper supports a describe/self-test mode for deterministic packaging validation.
- Runtime verification launches the application, starts a new recording, and captures the visible macOS menu-bar recording state.
@@ -0,0 +1,98 @@
# Implementation Evidence
## Automated
- `dotnet test MeetingAssistant.Tests/MeetingAssistant.Tests.csproj --filter FullyQualifiedName~MacOsDesktopControlManifestTests --no-restore`
- Passed: 1, Failed: 0.
- `dotnet build MeetingAssistant/MeetingAssistant.csproj -f net10.0 --no-restore`
- Passed and produced `Native/macos-desktop-controls` as an arm64 Mach-O executable.
- `openspec validate add-macos-desktop-controls --strict`
- Passed.
- Full portable test project:
- Passed: 427.
- Failed: 9 existing macOS/environment-dependent tests: Windows path expectations, unavailable GDI+, and user-environment key resolution.
- Whole solution:
- Cannot build the Windows target on macOS without enabling cross-Windows targeting (`NETSDK1100`).
## Runtime
- Installed the rebuilt application assembly and both native helpers into the existing local LaunchAgent application directory.
- LaunchAgent restarted successfully.
- `/health` returned `ok`.
- `macos-desktop-controls` ran as a child companion process.
- The helper manifest contained six configured hotkeys, including the unchanged defaults `Ctrl+Alt+M`, `Ctrl+Alt+L`, `Ctrl+Alt+Z`, and `Ctrl+Alt+S`.
- A fresh default-profile recording request created the `20260724-2015` meeting note and linked artifacts.
- Screenshot evidence: `tmp/macos-desktop-recording-verification.png`.
## Live desktop-control proof (2026-07-24)
- The installed LaunchAgent ran:
- `.NET host` PID `23023`
- native `macos-desktop-controls` PID `23097`
- The native helper manifest exposed:
- `Ctrl+Alt+M` -> `/profiles/default/recording/toggle`
- `Ctrl+Alt+L` -> `/profiles/english/recording/toggle`
- `Ctrl+Alt+Z` -> `/profiles/default/recording/abort`
- `Ctrl+Alt+S` -> `/profiles/default/meetings/screenshot/capture`
- Accessibility inspection of the real AppKit menu returned:
- `Meeting Assistant — idle`
- `Open agent`
- `Start meeting recording (default)`
- `Start meeting recording (english)`
- `Exit`
- Visible menu screenshot:
- `tmp/proof-macos-menu-idle.png`
- The menu visibly displays the macOS equivalents `⌃⌥M` and `⌃⌥L`.
- Clicking `Start meeting recording (default)` through the real menu produced the sampled status transition `false,true,false`, with `launchProfile: default`, and created:
- `Meetings/Notes/20260724-2022-note.md`
- `Meetings/Transcripts/20260724-2022-transcript.md`
- `Meetings/Assistant Context/20260724-2022-context.md`
- Posting the HID-level `Ctrl+Alt+M` combination produced the sampled status transition `false,true,false`, with `launchProfile: default`.
- Posting the HID-level `Ctrl+Alt+L` combination produced an active sampled state with `launchProfile: english`.
- Raw sampled status evidence:
- `tmp/proof-menu-recording-status.jsonl`
- `tmp/proof-hotkey-recording-status.jsonl`
- `tmp/proof-english-hotkey-status.jsonl`
## Runtime limitation
The new recording did not remain active because the local audio capture process stopped shortly after artifact creation. The desktop-control helper, menu-bar item, endpoint routing, and meeting creation were verified; sustained audio capture was not.
## Open agent regression and runtime proof
- Original deterministic regression:
- `MacOsApplicationRegistersARealInteractiveAgentWindow`
- Red result: portable registration resolved exactly `NoopWorkflowRulesEditorWindowService`.
- Fix:
- macOS now registers `MacOsWorkflowRulesEditorWindowService`.
- `Open agent` in the native helper creates an AppKit `NSWindow` titled `Meeting Summary Agent`.
- The window hosts a WebKit chat page backed by the existing `WorkflowRulesEditorChatViewModel` and agent pipeline.
- Green regression:
- Passed: 1, Failed: 0.
- Live installed runtime:
- Clicking the actual menu item created an on-screen CoreGraphics window:
- owner: `macos-desktop-controls`
- title: `Meeting Summary Agent`
- layer: `0`
- bounds: `756x714`
- `GET /diagnostics/settings-and-logs` returned the interactive agent HTML page.
## Open agent prompt execution proof (2026-07-25)
- Original live failure:
- `Meeting Summary Agent failed: No workflow rules editor API key configured.`
- Root cause:
- The LaunchAgent did not inherit the interactive shell's model API key.
- Its configured `127.0.0.1:4021` model endpoint had no running service.
- Runtime configuration:
- The protected LaunchAgent environment file now supplies the local LiteLLM gateway key.
- `MeetingAssistant__WorkflowRulesEditor__Endpoint` targets the running local gateway on port `4000`.
- The local gateway exposes the Responses-compatible `chatgpt-gpt-5.5` model alias.
- Event-stream compatibility regression:
- `ClientReconstructsResponseFromServerSentEvents` failed before the fix with `JsonReaderException`.
- The client now reconstructs completed response items from LiteLLM server-sent events.
- All 11 `LiteLlmResponsesChatClientTests` pass.
- Live installed proof:
- `POST /diagnostics/settings-and-logs/chat` with `Reply with exactly: key-ok`
returned `{"role":"agent","content":"key-ok"}`.
- `/health` returned successfully after deployment.
@@ -0,0 +1,24 @@
## Why
Meeting Assistant can record audio on macOS, but the portable build does not register the configured global shortcuts or provide the menu-bar controls used for the normal Windows desktop workflow. macOS users must call HTTP endpoints manually.
## What Changes
- Add a native macOS menu-bar companion that starts and stops with Meeting Assistant.
- Register the same configured recording, abort, and screenshot shortcuts used on Windows.
- Expose recording/profile controls, `Open agent`, and exit from the macOS menu bar.
- Keep recording lifecycle behavior behind the existing local HTTP/service surface.
## Capabilities
### Modified Capabilities
- `meeting-recording`: configured global recording controls work on macOS.
- `meeting-session`: the desktop control menu is available from the macOS menu bar.
## Impact
- `MeetingAssistant/Native/MacOsDesktopControls`
- portable macOS build/publish output
- startup service registration
- desktop-control behavior tests and macOS runtime documentation
@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: Recording mode is controlled by a configurable hotkey
Meeting Assistant SHALL use normal .NET configuration to define global hotkeys that work on Windows and macOS and toggle recording/transcription mode.
Meeting Assistant SHALL preserve the configured shortcut strings across Windows and macOS, including the defaults `Ctrl+Alt+M`, `Ctrl+Alt+L`, `Ctrl+Alt+Z`, and `Ctrl+Alt+S`.
Meeting Assistant SHALL expose a configurable abort/discard hotkey for an active recording on Windows and macOS.
#### Scenario: macOS uses the configured Windows-equivalent shortcuts
- **GIVEN** the default and English profiles use `Ctrl+Alt+M` and `Ctrl+Alt+L`
- **AND** abort and screenshot use `Ctrl+Alt+Z` and `Ctrl+Alt+S`
- **WHEN** Meeting Assistant starts on macOS
- **THEN** it registers those same global shortcut combinations
- **AND** routes them to toggle, abort, and screenshot actions through the local service surface
#### Scenario: macOS recording hotkey starts recording
- **GIVEN** Meeting Assistant is idle on macOS
- **WHEN** the user presses the configured recording hotkey
- **THEN** Meeting Assistant starts recording/transcription mode
@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
Meeting Assistant SHALL expose desktop controls from the Windows tray icon and the macOS menu bar.
The macOS menu-bar control SHALL start with Meeting Assistant, show current recording state, expose each configured launch profile with its configured hotkey, expose stop and abort controls while recording, expose `Open agent`, and expose exit.
Selecting `Open agent` on macOS SHALL open a native window titled `Meeting Summary Agent` containing the interactive settings-and-logs chat surface.
#### Scenario: macOS menu bar shows idle recording controls
- **GIVEN** Meeting Assistant is idle on macOS
- **WHEN** the user opens the menu-bar control
- **THEN** it shows `Open agent`
- **AND** shows a start action for every configured launch profile
- **AND** displays each profile's configured toggle hotkey
- **AND** shows an exit action
#### Scenario: macOS menu bar shows active recording controls
- **GIVEN** a meeting is recording on macOS
- **WHEN** the user opens the menu-bar control
- **THEN** it shows the active recording state
- **AND** exposes stop and abort actions
#### Scenario: macOS menu opens the interactive agent
- **GIVEN** Meeting Assistant is running on macOS
- **WHEN** the user selects `Open agent` from the menu-bar control
- **THEN** a `Meeting Summary Agent` window opens
- **AND** the user can submit a chat message and receive the interactive agent response
@@ -0,0 +1,18 @@
## 1. Contract and behavior
- [x] 1.1 Define macOS desktop-control requirements and design.
- [x] 1.2 Add a failing behavior test for configured shortcut and endpoint preservation.
- [x] 1.3 Implement the desktop-control manifest and make the behavior test pass.
## 2. Native macOS integration
- [x] 2.1 Add and package the native macOS menu-bar/global-hotkey helper.
- [x] 2.2 Start and stop the helper with the .NET application on macOS.
- [x] 2.3 Add deterministic helper packaging/self-test coverage.
## 3. Verification and closeout
- [x] 3.1 Run narrow and full tests.
- [x] 3.2 Run strict OpenSpec validation.
- [x] 3.3 Launch Meeting Assistant, start a new recording, and save screenshot evidence.
- [x] 3.4 Perform required DRY, SOLID, and KISS refactoring reviews and re-run verification.
+40
View File
@@ -375,3 +375,43 @@ After repairing a meeting or summary, the instructions SHALL direct the agent to
- **WHEN** the user asks the interactive agent to fix that meeting or its summary - **WHEN** the user asks the interactive agent to fix that meeting or its summary
- **THEN** the agent instructions direct it to inspect the matching assistant context for relevant meeting-specific memory - **THEN** the agent instructions direct it to inspect the matching assistant context for relevant meeting-specific memory
- **AND** direct it to append its fixes and conclusions to that assistant context - **AND** direct it to append its fixes and conclusions to that assistant context
### Requirement: Interactive agent uses detected host operating-system context
Meeting Assistant SHALL detect the host operating system when the application starts and SHALL make that immutable runtime context available to the interactive settings/logs assistant.
When the detected host is Windows, the interactive assistant instructions SHALL identify Windows as the runtime platform and SHALL direct the assistant to use Windows-specific commands, paths, services, environment conventions, and desktop concepts instead of macOS concepts.
When the detected host is macOS, the interactive assistant instructions SHALL identify macOS as the runtime platform and SHALL direct the assistant to use macOS-specific commands, POSIX paths, launchd services, environment conventions, and desktop concepts instead of Windows concepts.
Platform-aware instructions SHALL remain present when the interactive assistant uses a configured custom initial prompt.
The instructions SHALL distinguish platform-aware operational guidance from feature availability and SHALL NOT imply that detecting macOS provides Windows-only or otherwise unavailable integrations.
For other detected hosts, Meeting Assistant SHALL identify the platform as unsupported for platform-specific guidance and SHALL direct the assistant to prefer portable tools and concepts rather than assuming Windows or macOS.
#### Scenario: Windows host receives Windows operational guidance
- **GIVEN** Meeting Assistant detected Windows during startup
- **WHEN** it builds the interactive settings/logs assistant instructions
- **THEN** the instructions identify Windows as the runtime platform
- **AND** direct the assistant to use Windows commands and concepts
- **AND** direct the assistant not to substitute macOS commands or concepts
#### Scenario: macOS host receives macOS operational guidance
- **GIVEN** Meeting Assistant detected macOS during startup
- **WHEN** it builds the interactive settings/logs assistant instructions
- **THEN** the instructions identify macOS as the runtime platform
- **AND** direct the assistant to use macOS commands and concepts
- **AND** direct the assistant not to substitute Windows commands or concepts
#### Scenario: Custom prompt retains detected platform context
- **GIVEN** Meeting Assistant detected macOS during startup
- **AND** a custom interactive-assistant initial prompt is configured
- **WHEN** it builds the interactive assistant instructions
- **THEN** the instructions contain both the custom prompt and macOS runtime guidance
#### Scenario: Unsupported host uses neutral guidance
- **GIVEN** Meeting Assistant runs on a host other than Windows or macOS
- **WHEN** it builds the interactive settings/logs assistant instructions
- **THEN** the instructions do not label the host as Windows or macOS
- **AND** direct the assistant to prefer portable tools and concepts