From 9542c05ea461b4594f7817f9156094c9b289f75c Mon Sep 17 00:00:00 2001 From: dh Date: Sat, 25 Jul 2026 16:31:38 +0200 Subject: [PATCH] feat(macos): add native desktop controls and hotkey integration - Introduced MacOsDesktopControlManifest to manage desktop control configurations. - Implemented MacOsDesktopControlService to handle the lifecycle of the macOS helper process. - Created MacOsWorkflowRulesEditorWindowService for managing workflow rules through a native window. - Developed a Swift helper for macOS that integrates with AppKit and Carbon for menu-bar controls and global hotkeys. - Added support for recording lifecycle actions via HTTP endpoints, maintaining consistency with existing Windows functionality. - Enhanced user experience by allowing macOS users to start/stop recordings and access settings through a menu-bar interface. - Documented design decisions, implementation evidence, and verification tasks for the new macOS features. --- .../MacOsDesktopControlManifestTests.cs | 89 ++++++ .../MacOs/MacOsDesktopControlManifest.cs | 47 +++ .../MacOs/MacOsDesktopControlService.cs | 107 +++++++ .../MacOsWorkflowRulesEditorWindowService.cs | 161 ++++++++++ MeetingAssistant/MeetingAssistant.csproj | 14 +- .../Native/MacOsDesktopControls/main.swift | 289 ++++++++++++++++++ MeetingAssistant/Program.cs | 24 +- README.md | 10 +- .../add-macos-desktop-controls/design.md | 23 ++ .../implementation-evidence.md | 98 ++++++ .../add-macos-desktop-controls/proposal.md | 24 ++ .../specs/meeting-recording/spec.md | 20 ++ .../specs/meeting-session/spec.md | 28 ++ .../add-macos-desktop-controls/tasks.md | 18 ++ .../design.md | 0 .../implementation-evidence.md | 0 .../proposal.md | 0 .../specs/meeting-session/spec.md | 0 .../tasks.md | 0 openspec/specs/meeting-session/spec.md | 40 +++ 20 files changed, 981 insertions(+), 11 deletions(-) create mode 100644 MeetingAssistant.Tests/MacOsDesktopControlManifestTests.cs create mode 100644 MeetingAssistant/MacOs/MacOsDesktopControlManifest.cs create mode 100644 MeetingAssistant/MacOs/MacOsDesktopControlService.cs create mode 100644 MeetingAssistant/MacOs/MacOsWorkflowRulesEditorWindowService.cs create mode 100644 MeetingAssistant/Native/MacOsDesktopControls/main.swift create mode 100644 openspec/changes/add-macos-desktop-controls/design.md create mode 100644 openspec/changes/add-macos-desktop-controls/implementation-evidence.md create mode 100644 openspec/changes/add-macos-desktop-controls/proposal.md create mode 100644 openspec/changes/add-macos-desktop-controls/specs/meeting-recording/spec.md create mode 100644 openspec/changes/add-macos-desktop-controls/specs/meeting-session/spec.md create mode 100644 openspec/changes/add-macos-desktop-controls/tasks.md rename openspec/changes/{detect-host-operating-system => archive/2026-07-25-detect-host-operating-system}/design.md (100%) rename openspec/changes/{detect-host-operating-system => archive/2026-07-25-detect-host-operating-system}/implementation-evidence.md (100%) rename openspec/changes/{detect-host-operating-system => archive/2026-07-25-detect-host-operating-system}/proposal.md (100%) rename openspec/changes/{detect-host-operating-system => archive/2026-07-25-detect-host-operating-system}/specs/meeting-session/spec.md (100%) rename openspec/changes/{detect-host-operating-system => archive/2026-07-25-detect-host-operating-system}/tasks.md (100%) diff --git a/MeetingAssistant.Tests/MacOsDesktopControlManifestTests.cs b/MeetingAssistant.Tests/MacOsDesktopControlManifestTests.cs new file mode 100644 index 0000000..b339c41 --- /dev/null +++ b/MeetingAssistant.Tests/MacOsDesktopControlManifestTests.cs @@ -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(); + + var service = factory.Services.GetRequiredService(); + + Assert.IsType(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 + } + }); + } +} diff --git a/MeetingAssistant/MacOs/MacOsDesktopControlManifest.cs b/MeetingAssistant/MacOs/MacOsDesktopControlManifest.cs new file mode 100644 index 0000000..171d58d --- /dev/null +++ b/MeetingAssistant/MacOs/MacOsDesktopControlManifest.cs @@ -0,0 +1,47 @@ +using MeetingAssistant.LaunchProfiles; + +namespace MeetingAssistant.MacOs; + +public sealed record MacOsDesktopControlManifest( + string BaseUrl, + string StatusUrl, + string OpenAgentUrl, + IReadOnlyList Profiles, + IReadOnlyList Hotkeys) +{ + public static MacOsDesktopControlManifest Create( + string baseUrl, + IReadOnlyList profiles, + IReadOnlyList 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); diff --git a/MeetingAssistant/MacOs/MacOsDesktopControlService.cs b/MeetingAssistant/MacOs/MacOsDesktopControlService.cs new file mode 100644 index 0000000..7bcc12b --- /dev/null +++ b/MeetingAssistant/MacOs/MacOsDesktopControlService.cs @@ -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 options; + private readonly IHostApplicationLifetime applicationLifetime; + private readonly ILogger logger; + private Process? helper; + private string? manifestPath; + + public MacOsDesktopControlService( + ILaunchProfileOptionsProvider launchProfiles, + IOptions options, + IHostApplicationLifetime applicationLifetime, + ILogger 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; + } + } +} diff --git a/MeetingAssistant/MacOs/MacOsWorkflowRulesEditorWindowService.cs b/MeetingAssistant/MacOs/MacOsWorkflowRulesEditorWindowService.cs new file mode 100644 index 0000000..476b6f8 --- /dev/null +++ b/MeetingAssistant/MacOs/MacOsWorkflowRulesEditorWindowService.cs @@ -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 options; + + public MacOsWorkflowRulesEditorWindowService(IOptions 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 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 Messages); + +public sealed record MacOsWorkflowRulesEditorMessage(string Role, string Content); + +public static class MacOsWorkflowRulesEditorPage +{ + public const string Html = """ + + + + + + Meeting Summary Agent + + + +
+

Meeting Summary Agent

+
+
+ + +
+
+ + + + """; +} diff --git a/MeetingAssistant/MeetingAssistant.csproj b/MeetingAssistant/MeetingAssistant.csproj index fad01a5..ed81a33 100644 --- a/MeetingAssistant/MeetingAssistant.csproj +++ b/MeetingAssistant/MeetingAssistant.csproj @@ -16,13 +16,15 @@ - true + true $(MSBuildProjectDirectory)/Native/MacOsMeetingAudioCapture/main.swift Native/macos-meeting-audio-capture $(RuntimeIdentifier) $(NETCoreSdkRuntimeIdentifier) x86_64 arm64 + $(MSBuildProjectDirectory)/Native/MacOsDesktopControls/main.swift + Native/macos-desktop-controls @@ -72,19 +74,21 @@ + Condition="'$(MacOsNativeHelpersEnabled)' == 'true'"> + + Condition="'$(MacOsNativeHelpersEnabled)' == 'true'"> + diff --git a/MeetingAssistant/Native/MacOsDesktopControls/main.swift b/MeetingAssistant/Native/MacOsDesktopControls/main.swift new file mode 100644 index 0000000..5930dbc --- /dev/null +++ b/MeetingAssistant/Native/MacOsDesktopControls/main.swift @@ -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 \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.size, + nil, + &identifier) + let owner = Unmanaged.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) } +} diff --git a/MeetingAssistant/Program.cs b/MeetingAssistant/Program.cs index 02f66a5..d53e028 100644 --- a/MeetingAssistant/Program.cs +++ b/MeetingAssistant/Program.cs @@ -3,6 +3,7 @@ using MeetingAssistant.Calendar; using MeetingAssistant.Hotkeys; using MeetingAssistant.LaunchProfiles; using MeetingAssistant.Logging; +using MeetingAssistant.MacOs; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Recording; using MeetingAssistant.Screenshots; @@ -38,6 +39,7 @@ builder.Services.AddSingleton(); +builder.Services.AddHostedService(); #endif builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -111,7 +113,8 @@ builder.Services.AddSingleton(services => WorkflowRulesEditorMarkdownLinkResolve services.GetRequiredService>().Value)); builder.Services.AddSingleton(); #else -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); #endif builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -152,6 +155,11 @@ app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Res app.MapGet("/profiles/{launchProfile}/recording/status", ( string launchProfile, MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus)); +app.MapPost("/application/exit", (IHostApplicationLifetime lifetime) => +{ + lifetime.StopApplication(); + return Results.Accepted(); +}); app.MapGet("/diagnostics/outlook/current-meeting", async ( IMeetingMetadataProvider metadataProvider, IOptions options, @@ -281,6 +289,15 @@ app.MapPost("/diagnostics/settings-and-logs/show", ( rulesEditorWindow.Show(); 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) { @@ -323,6 +340,11 @@ app.MapPost("/profiles/{launchProfile}/recording/abort", async ( MeetingRecordingCoordinator coordinator, CancellationToken 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 ( MeetingRecordingCoordinator coordinator, IMeetingSummaryPipeline summaryPipeline, diff --git a/README.md b/README.md index 600a101..701e74c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This repository owns the application source, tests, OpenSpec requirements, confi 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. -- 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. - 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`. @@ -32,7 +32,7 @@ dotnet build 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: @@ -58,7 +58,7 @@ Do not restart while a meeting is recording, transcribing, finalizing speaker re ## 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+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. -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`. diff --git a/openspec/changes/add-macos-desktop-controls/design.md b/openspec/changes/add-macos-desktop-controls/design.md new file mode 100644 index 0000000..6260f4d --- /dev/null +++ b/openspec/changes/add-macos-desktop-controls/design.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. diff --git a/openspec/changes/add-macos-desktop-controls/implementation-evidence.md b/openspec/changes/add-macos-desktop-controls/implementation-evidence.md new file mode 100644 index 0000000..5ce33bb --- /dev/null +++ b/openspec/changes/add-macos-desktop-controls/implementation-evidence.md @@ -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. diff --git a/openspec/changes/add-macos-desktop-controls/proposal.md b/openspec/changes/add-macos-desktop-controls/proposal.md new file mode 100644 index 0000000..8d8593e --- /dev/null +++ b/openspec/changes/add-macos-desktop-controls/proposal.md @@ -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 diff --git a/openspec/changes/add-macos-desktop-controls/specs/meeting-recording/spec.md b/openspec/changes/add-macos-desktop-controls/specs/meeting-recording/spec.md new file mode 100644 index 0000000..648af53 --- /dev/null +++ b/openspec/changes/add-macos-desktop-controls/specs/meeting-recording/spec.md @@ -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 diff --git a/openspec/changes/add-macos-desktop-controls/specs/meeting-session/spec.md b/openspec/changes/add-macos-desktop-controls/specs/meeting-session/spec.md new file mode 100644 index 0000000..7190b39 --- /dev/null +++ b/openspec/changes/add-macos-desktop-controls/specs/meeting-session/spec.md @@ -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 diff --git a/openspec/changes/add-macos-desktop-controls/tasks.md b/openspec/changes/add-macos-desktop-controls/tasks.md new file mode 100644 index 0000000..c125925 --- /dev/null +++ b/openspec/changes/add-macos-desktop-controls/tasks.md @@ -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. diff --git a/openspec/changes/detect-host-operating-system/design.md b/openspec/changes/archive/2026-07-25-detect-host-operating-system/design.md similarity index 100% rename from openspec/changes/detect-host-operating-system/design.md rename to openspec/changes/archive/2026-07-25-detect-host-operating-system/design.md diff --git a/openspec/changes/detect-host-operating-system/implementation-evidence.md b/openspec/changes/archive/2026-07-25-detect-host-operating-system/implementation-evidence.md similarity index 100% rename from openspec/changes/detect-host-operating-system/implementation-evidence.md rename to openspec/changes/archive/2026-07-25-detect-host-operating-system/implementation-evidence.md diff --git a/openspec/changes/detect-host-operating-system/proposal.md b/openspec/changes/archive/2026-07-25-detect-host-operating-system/proposal.md similarity index 100% rename from openspec/changes/detect-host-operating-system/proposal.md rename to openspec/changes/archive/2026-07-25-detect-host-operating-system/proposal.md diff --git a/openspec/changes/detect-host-operating-system/specs/meeting-session/spec.md b/openspec/changes/archive/2026-07-25-detect-host-operating-system/specs/meeting-session/spec.md similarity index 100% rename from openspec/changes/detect-host-operating-system/specs/meeting-session/spec.md rename to openspec/changes/archive/2026-07-25-detect-host-operating-system/specs/meeting-session/spec.md diff --git a/openspec/changes/detect-host-operating-system/tasks.md b/openspec/changes/archive/2026-07-25-detect-host-operating-system/tasks.md similarity index 100% rename from openspec/changes/detect-host-operating-system/tasks.md rename to openspec/changes/archive/2026-07-25-detect-host-operating-system/tasks.md diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index a922882..85e0381 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -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 - **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 + +### 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 +