From 5ca827866356e7115292de35df5ab432101686d7 Mon Sep 17 00:00:00 2001 From: rrader26 Date: Tue, 12 May 2026 07:17:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20System=20Pack=20=E2=80=94=20na?= =?UTF-8?q?tive=20OS=20notifications=20+=20TTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surfaces that turn a background agent into something that can politely interrupt the human without flipping them to a chat window. Both cross-platform via OS-shipped utilities; no Node deps. Tools shipped (2): agentmark_notify native OS notification (title + body) agentmark_voice_speak text-to-speech via OS engine Platform mapping: macOS: osascript display notification + say Windows: PowerShell Windows.UI.Notifications + System.Speech.Synthesis Linux: notify-send (libnotify-bin) + espeak-ng / espeak Use cases: - "I'm waiting on your input" → notify - "Background job complete" → notify with sound - Mid-flow status updates without screen takeover → speak - Accessibility-oriented audio confirmation of completed tasks Both are async-fire-and-forget; agents don't wait for user reaction. A future PR adds voice_listen (STT) + interactive notification buttons. Tests (4 new, 346 total): plugin registration, tool surface, argument validation. Live OS-toast / TTS is smoke-tested on Mac + Win VMs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 13 ++++ src/plugins/system/index.ts | 95 ++++++++++++++++++++++++++++ src/plugins/system/notify.ts | 101 ++++++++++++++++++++++++++++++ src/plugins/system/voice.ts | 92 +++++++++++++++++++++++++++ test/system/system-plugin.test.ts | 47 ++++++++++++++ 5 files changed, 348 insertions(+) create mode 100644 src/plugins/system/index.ts create mode 100644 src/plugins/system/notify.ts create mode 100644 src/plugins/system/voice.ts create mode 100644 test/system/system-plugin.test.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 4c61986..b023b5b 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -87,6 +87,19 @@ export type { ProcessDetail, } from '../plugins/process' +// System Pack — native OS notifications + text-to-speech. Opt-in. +export { + createSystemPlugin, + notify, + speak, + SYSTEM_TOOLS, +} from '../plugins/system' +export type { + SystemPluginConfig, + NotifyOptions, + SpeakOptions, +} from '../plugins/system' + // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the // default plugin set. Pass it explicitly via `createMcpServer({ plugins })`. export { diff --git a/src/plugins/system/index.ts b/src/plugins/system/index.ts new file mode 100644 index 0000000..e4ee6d0 --- /dev/null +++ b/src/plugins/system/index.ts @@ -0,0 +1,95 @@ +/** + * System Pack — native OS notifications + text-to-speech. + * + * Two surfaces that turn a background agent into something that can + * politely interrupt the human without flipping them to a chat window. + * Both cross-platform via OS-shipped utilities; no Node deps. + */ +import { notify, type NotifyOptions } from './notify' +import { speak, type SpeakOptions } from './voice' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin' +import type { McpToolDef } from '../../mcp/tool-defs' + +const SYSTEM_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_notify', + description: + 'Display a native OS notification. Use to ping the human ' + + 'mid-flow ("I need confirmation", "job finished") without ' + + 'taking over their screen. macOS, Windows 10+, Linux (libnotify).', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string' }, + body: { type: 'string', description: 'Notification body text.' }, + subtitle: { type: 'string', description: 'Subtitle (macOS only; other platforms append to title).' }, + sound: { type: 'boolean', description: 'Play the default notification sound. Default: false.' }, + }, + required: ['title'], + }, + }, + { + name: 'agentmark_voice_speak', + description: + 'Speak the given text via the OS text-to-speech engine. macOS ' + + '`say`, Windows SAPI (System.Speech.Synthesis), Linux espeak. ' + + 'Voice + rate are optional and platform-specific.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string' }, + voice: { type: 'string', description: 'Voice name (platform-specific).' }, + rate: { type: 'number', description: 'Words per minute (roughly).' }, + }, + required: ['text'], + }, + }, +] + +export interface SystemPluginConfig { + /** Reserved for future config (e.g. preferred voice, default sound). */ + _reserved?: never +} + +export function createSystemPlugin(_config: SystemPluginConfig = {}): AgentMarkPlugin { + const handlers: Record = { + agentmark_notify: async (args): Promise => { + const title = requireString(args, 'title') + await notify({ + title, + body: typeof args.body === 'string' ? args.body : undefined, + subtitle: typeof args.subtitle === 'string' ? args.subtitle : undefined, + sound: args.sound === true, + }) + return { text: JSON.stringify({ sent: true, title }, null, 2) } + }, + + agentmark_voice_speak: async (args): Promise => { + const text = requireString(args, 'text') + await speak({ + text, + voice: typeof args.voice === 'string' ? args.voice : undefined, + rate: typeof args.rate === 'number' ? args.rate : undefined, + }) + return { text: JSON.stringify({ spoken: true, bytes: text.length }, null, 2) } + }, + } + + return { + name: 'system', + version: '0.1.0', + tools: SYSTEM_TOOLS, + handlers, + } +} + +export { notify, speak, SYSTEM_TOOLS } +export type { NotifyOptions, SpeakOptions } + +function requireString(args: Record, key: string): string { + const v = args[key] + if (typeof v !== 'string' || v.length === 0) { + throw new Error(`Missing required argument: ${key}`) + } + return v +} diff --git a/src/plugins/system/notify.ts b/src/plugins/system/notify.ts new file mode 100644 index 0000000..897e420 --- /dev/null +++ b/src/plugins/system/notify.ts @@ -0,0 +1,101 @@ +/** + * Native OS notifications. + * + * macOS: osascript display notification (always installed) + * Windows: PowerShell + Windows.UI.Notifications (built into Win 10+) + * Linux: notify-send (libnotify-bin; standard on most desktops) + * + * Used by agents to ping the human: "I'm waiting on you", "background + * job finished", "I need confirmation". Display only — there's no + * "did the user click?" callback; we don't need full toast actions + * yet. + */ +import { spawn } from 'node:child_process' + +export interface NotifyOptions { + title: string + body?: string + /** Optional subtitle (macOS only — other platforms append to title). */ + subtitle?: string + /** Play the default notification sound. Default: false. */ + sound?: boolean +} + +export async function notify(opts: NotifyOptions): Promise { + if (!opts.title) throw new Error('notify: title is required.') + + if (process.platform === 'darwin') { + await notifyMac(opts) + } else if (process.platform === 'win32') { + await notifyWindows(opts) + } else { + await notifyLinux(opts) + } +} + +async function notifyMac(opts: NotifyOptions): Promise { + // osascript -e 'display notification "body" with title "title" subtitle "..." sound name "default"' + const parts: string[] = [`display notification "${escapeAppleScript(opts.body ?? '')}" with title "${escapeAppleScript(opts.title)}"`] + if (opts.subtitle) parts.push(`subtitle "${escapeAppleScript(opts.subtitle)}"`) + if (opts.sound) parts.push('sound name "default"') + await runCommand('osascript', ['-e', parts.join(' ')]) +} + +async function notifyWindows(opts: NotifyOptions): Promise { + // Use the Windows.UI.Notifications toast API — built into Win 10/11. + // No external module install required. + const title = escapeForPowerShell(opts.title) + const body = escapeForPowerShell(opts.body ?? opts.subtitle ?? '') + const script = [ + '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null', + '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null', + `$xml = '${title}${body}'`, + '$doc = New-Object Windows.Data.Xml.Dom.XmlDocument', + '$doc.LoadXml($xml)', + '$toast = [Windows.UI.Notifications.ToastNotification]::new($doc)', + '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("AgentMark").Show($toast)', + ].join('; ') + await runCommand('powershell', ['-NoProfile', '-Command', script]) +} + +async function notifyLinux(opts: NotifyOptions): Promise { + const args = [opts.title] + if (opts.body) args.push(opts.body) + try { + await runCommand('notify-send', args) + } catch { + throw new Error( + 'Linux notifications require `notify-send` from libnotify-bin. ' + + 'Install: `apt install libnotify-bin` (Debian/Ubuntu).', + ) + } +} + +function escapeAppleScript(s: string): string { + // AppleScript strings are double-quoted; escape backslashes and quotes. + return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +function escapeForPowerShell(s: string): string { + // We're emitting a literal XML string inside single-quoted PowerShell, + // then loading via LoadXml — escape XML special chars + the single quote + // that would close the PS string. + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, "''") +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = '' + child.stderr.on('data', (b: Buffer) => { stderr += b.toString('utf8') }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} exited ${code}: ${stderr.trim()}`)) + }) + }) +} diff --git a/src/plugins/system/voice.ts b/src/plugins/system/voice.ts new file mode 100644 index 0000000..a815e32 --- /dev/null +++ b/src/plugins/system/voice.ts @@ -0,0 +1,92 @@ +/** + * Native OS text-to-speech. + * + * macOS: `say "text"` (always installed) + * Windows: PowerShell + System.Speech.Synthesis (built into .NET on Win) + * Linux: espeak / espeak-ng (install separately) + * + * Asynchronous — returns once the OS has accepted the request, but the + * audio playback continues in background. Agents that want to wait for + * playback completion need a future `voice_listen` integration that + * isn't in v0. + */ +import { spawn } from 'node:child_process' + +export interface SpeakOptions { + /** Text to vocalise. */ + text: string + /** Voice name (platform-specific). macOS: say -v ?, Win: SAPI installed voice. */ + voice?: string + /** Words per minute. Maps to platform-native rate scaling. */ + rate?: number +} + +export async function speak(opts: SpeakOptions): Promise { + if (!opts.text) throw new Error('speak: text is required.') + + if (process.platform === 'darwin') { + await speakMac(opts) + } else if (process.platform === 'win32') { + await speakWindows(opts) + } else { + await speakLinux(opts) + } +} + +async function speakMac(opts: SpeakOptions): Promise { + const args: string[] = [] + if (opts.voice) args.push('-v', opts.voice) + if (typeof opts.rate === 'number') args.push('-r', String(opts.rate)) + args.push(opts.text) + await runCommand('say', args) +} + +async function speakWindows(opts: SpeakOptions): Promise { + // SAPI rate is -10..+10 mapping to roughly 100..400 wpm. Convert from + // wpm if supplied: 100 → -10, 250 → 0, 400 → +10 (linear). + const rateStmt = typeof opts.rate === 'number' + ? `; $synth.Rate = [int]([math]::Max(-10, [math]::Min(10, ($args[0]) / 25 - 10))) -as [int]` + : '' + const voiceStmt = opts.voice ? `; $synth.SelectVoice('${escapeForPs(opts.voice)}')` : '' + const text = escapeForPs(opts.text) + const script = `Add-Type -AssemblyName System.Speech; $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer${voiceStmt}${rateStmt}; $synth.Speak('${text}')` + const args = ['-NoProfile', '-Command', script] + if (typeof opts.rate === 'number') args.push(String(opts.rate)) + await runCommand('powershell', args) +} + +async function speakLinux(opts: SpeakOptions): Promise { + const args: string[] = [] + if (opts.voice) args.push('-v', opts.voice) + if (typeof opts.rate === 'number') args.push('-s', String(opts.rate)) + args.push(opts.text) + for (const cmd of ['espeak-ng', 'espeak'] as const) { + try { + await runCommand(cmd, args) + return + } catch { + continue + } + } + throw new Error( + 'Linux TTS requires espeak-ng or espeak. ' + + 'Install: `apt install espeak-ng` (Debian/Ubuntu).', + ) +} + +function escapeForPs(s: string): string { + return s.replace(/'/g, "''") +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = '' + child.stderr.on('data', (b: Buffer) => { stderr += b.toString('utf8') }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} exited ${code}: ${stderr.trim()}`)) + }) + }) +} diff --git a/test/system/system-plugin.test.ts b/test/system/system-plugin.test.ts new file mode 100644 index 0000000..d6d07d5 --- /dev/null +++ b/test/system/system-plugin.test.ts @@ -0,0 +1,47 @@ +/** + * Tests for the System Pack. + * + * The actual notify/speak operations have no programmatic verification + * path (they pop OS toast / play audio). We test plugin registration + * and argument validation; the live execution is smoke-tested via the + * Mac + Win VMs. + */ +import { describe, it, expect } from 'vitest' +import { + createSystemPlugin, + SYSTEM_TOOLS, +} from '../../src/plugins/system' +import { Dispatcher } from '../../src/mcp/plugin' + +describe('System plugin — registration', () => { + it('registers every tool with a matching handler', () => { + const plugin = createSystemPlugin() + const dispatcher = new Dispatcher([plugin]) + expect(dispatcher.toolNames.sort()).toEqual(SYSTEM_TOOLS.map((t) => t.name).sort()) + }) + + it('exposes the v0 tool set', () => { + expect(SYSTEM_TOOLS.map((t) => t.name).sort()).toEqual([ + 'agentmark_notify', + 'agentmark_voice_speak', + ]) + }) +}) + +describe('System plugin — argument validation', () => { + it('agentmark_notify requires title', async () => { + const plugin = createSystemPlugin() + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_notify', {}) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/title/) + }) + + it('agentmark_voice_speak requires text', async () => { + const plugin = createSystemPlugin() + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_voice_speak', {}) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/text/) + }) +})