Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
95 changes: 95 additions & 0 deletions src/plugins/system/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, ToolHandler> = {
agentmark_notify: async (args): Promise<DispatchResult> => {
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<DispatchResult> => {
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<string, unknown>, key: string): string {
const v = args[key]
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`Missing required argument: ${key}`)
}
return v
}
101 changes: 101 additions & 0 deletions src/plugins/system/notify.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
// 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<void> {
// 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 = '<toast><visual><binding template="ToastGeneric"><text>${title}</text><text>${body}</text></binding></visual></toast>'`,
'$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<void> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, "''")
}

function runCommand(command: string, args: string[]): Promise<void> {
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()}`))
})
})
}
92 changes: 92 additions & 0 deletions src/plugins/system/voice.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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()}`))
})
})
}
47 changes: 47 additions & 0 deletions test/system/system-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
Loading