UNPKG

@sogni-ai/sogni-creative-agent-skill

Version:

Sogni Creative Agent Skill: agent skill and CLI for Sogni AI image, video, and music generation.

338 lines (330 loc) 14.9 kB
// desktop-extension/server/tools.mjs // MCP tool registry: JSON Schemas shown to Claude Desktop plus pure // buildArgs() functions that translate tool input into sogni-agent argv. // buildArgs never spawns anything — index.mjs owns process execution. const GEN_BASE = ['--json', '-q', '--no-update-check']; function push(args, flag, value) { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); } function required(input, field, hint) { const v = input[field]; if (v === undefined || v === null || v === '') { throw new Error(`${field} is required${hint ? ` — ${hint}` : ''}.`); } return v; } const str = (description) => ({ type: 'string', description }); const num = (description) => ({ type: 'number', description }); // Chat clients (Claude Desktop etc.) never expose chat-attached files to this // server's filesystem; without this warning the model wastes turns discovering // that boundary by trial and error. const CHAT_ATTACHMENT_NOTE = 'Files attached to the chat are NOT visible to Sogni tools — import them with import_media first ' + '(returns a local path), or pass a local absolute path, URL, or saved persona.'; export const TOOLS = [ { name: 'generate_image', description: 'Generate one or more images from a text prompt on the Sogni GPU network. ' + 'Pass output_path (absolute, .png/.jpg) to save locally; otherwise a hosted URL is returned. ' + 'Use context_images for image editing ("make the background a beach") and persona to include a saved person. ' + 'Image results are attached inline in the tool result. ' + CHAT_ATTACHMENT_NOTE, inputSchema: { type: 'object', properties: { prompt: str('What to generate'), output_path: str('Absolute file path to save the image (optional)'), quality: { type: 'string', enum: ['fast', 'hq', 'pro'], description: 'Quality preset' }, model: str('Model id (optional; overrides quality preset)'), width: num('Width in px (default 512)'), height: num('Height in px (default 512)'), count: num('Number of images (default 1)'), seed: num('Fixed seed for reproducibility'), context_images: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of context/edit images' }, persona: str('Saved persona name to use as reference'), timeout_seconds: num('Generation timeout override'), }, required: ['prompt'], }, buildArgs(input) { const prompt = required(input, 'prompt'); const args = [...GEN_BASE]; push(args, '-o', input.output_path); push(args, '-Q', input.quality); push(args, '-m', input.model); push(args, '-w', input.width); push(args, '-h', input.height); push(args, '-n', input.count); push(args, '-s', input.seed); for (const c of input.context_images ?? []) push(args, '-c', c); push(args, '--persona', input.persona); push(args, '-t', input.timeout_seconds); args.push(prompt); return args; }, }, { name: 'generate_video', description: 'Generate a video from a text prompt, optionally driven by reference media. ' + 'ref = start frame image, ref_end = end frame, ref_audio = soundtrack/lip-sync audio, ref_video = motion reference. ' + 'Rendering takes minutes; prefer output_path (absolute .mp4). ' + CHAT_ATTACHMENT_NOTE, inputSchema: { type: 'object', properties: { prompt: str('Motion/scene description'), output_path: str('Absolute .mp4 path to save (optional)'), model: str('Video model id (optional)'), workflow: { type: 'string', enum: ['t2v', 'i2v', 's2v', 'ia2v', 'a2v', 'v2v', 'animate-move', 'animate-replace'] }, duration: num('Duration in seconds (default 5)'), ref: str('Absolute path or URL of the start-frame image'), ref_end: str('Absolute path or URL of the end-frame image'), ref_audio: str('Absolute path or URL of reference audio'), ref_video: str('Absolute path or URL of reference video'), persona: str('Saved persona name (reference frame)'), target_resolution: num('Short-side target in px, preserves aspect'), timeout_seconds: num('Generation timeout override (default 300)'), }, required: ['prompt'], }, buildArgs(input) { const prompt = required(input, 'prompt'); const args = [...GEN_BASE, '--video']; push(args, '-o', input.output_path); push(args, '-m', input.model); push(args, '--workflow', input.workflow); push(args, '--duration', input.duration); push(args, '--ref', input.ref); push(args, '--ref-end', input.ref_end); push(args, '--ref-audio', input.ref_audio); push(args, '--ref-video', input.ref_video); push(args, '--persona', input.persona); push(args, '--target-resolution', input.target_resolution); push(args, '-t', input.timeout_seconds); args.push(prompt); return args; }, }, { name: 'generate_music', description: 'Generate music/audio from a text prompt. Omit lyrics for an instrumental. ' + 'Duration 10-600 seconds. Prefer output_path (absolute .mp3/.wav/.flac).', inputSchema: { type: 'object', properties: { prompt: str('Style/mood description'), output_path: str('Absolute audio file path to save (optional)'), lyrics: str('Song lyrics (optional)'), duration: num('Seconds, 10-600 (default 30)'), bpm: num('Beats per minute (30-300)'), keyscale: str('Key/scale, e.g. "C major"'), music_model: str('turbo | sft | ace_step_1.5_turbo | ace_step_1.5_sft'), audio_format: { type: 'string', enum: ['mp3', 'flac', 'wav'] }, timeout_seconds: num('Generation timeout override (default 600)'), }, required: ['prompt'], }, buildArgs(input) { const prompt = required(input, 'prompt'); const args = [...GEN_BASE, '--music']; push(args, '-o', input.output_path); push(args, '--music-model', input.music_model); push(args, '--lyrics', input.lyrics); push(args, '--duration', input.duration); push(args, '--bpm', input.bpm); push(args, '--keyscale', input.keyscale); push(args, '--output-format', input.audio_format); push(args, '-t', input.timeout_seconds); args.push(prompt); return args; }, }, { name: 'photobooth', description: 'Face-transfer portrait generation: renders the person in the ref photo into a new scene ' + '(e.g. "LinkedIn professional headshot", "80s fashion portrait"). ' + 'Image results are attached inline in the tool result. ' + CHAT_ATTACHMENT_NOTE, inputSchema: { type: 'object', properties: { prompt: str('Scene/style description'), ref: str('Absolute path or URL of the face photo'), output_path: str('Absolute image path to save (optional)'), count: num('Number of variations (default 1)'), }, required: ['prompt', 'ref'], }, buildArgs(input) { const prompt = required(input, 'prompt'); const ref = required(input, 'ref', 'a face photo path or URL'); const args = [...GEN_BASE, '--photobooth', '--ref', String(ref)]; push(args, '-n', input.count); push(args, '-o', input.output_path); args.push(prompt); return args; }, }, { name: 'edit_video', description: 'Safe local video utilities (ffmpeg wrappers): extract the first/last frame, ' + 'concatenate clips (with optional soundtrack), or remix the audio of an existing video. ' + 'Extracted frames are attached inline in the tool result.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['extract_first_frame', 'extract_last_frame', 'concat_videos', 'remix_audio'] }, input: str('Input video path (extract/remix actions)'), output: str('Output file path'), clips: { type: 'array', items: { type: 'string' }, description: 'Clip paths for concat_videos (min 2)' }, audio_path: str('Soundtrack to mux over concat_videos output'), bed_audio: str('Audio bed for remix_audio'), audio_loop: { type: 'boolean', description: 'Loop the bed to cover the video (remix_audio)' }, }, required: ['action', 'output'], }, buildArgs(input) { const action = required(input, 'action'); if (action === 'extract_first_frame' || action === 'extract_last_frame') { const src = required(input, 'input', 'the source video'); const output = required(input, 'output'); return ['--no-update-check', `--${action.replaceAll('_', '-')}`, String(src), String(output)]; } if (action === 'concat_videos') { const output = required(input, 'output'); const clips = input.clips ?? []; if (clips.length < 2) throw new Error('concat_videos needs at least two clips.'); const args = ['--no-update-check', '--concat-videos', String(output), ...clips.map(String)]; push(args, '--concat-audio', input.audio_path); return args; } if (action === 'remix_audio') { const src = required(input, 'input', 'the source video'); const output = required(input, 'output'); const args = ['--no-update-check', '--remix-audio', String(src), String(output)]; push(args, '--bed-audio', input.bed_audio); if (input.audio_loop) args.push('--audio-loop'); return args; } throw new Error(`Unknown action "${action}".`); }, }, { name: 'import_media', description: 'Save base64-encoded media bytes to the local Sogni media inbox and return the absolute file path, ' + 'ready to pass as ref / context_images / persona ref. Use this to hand chat-attached files to the ' + 'generation tools: read the attachment bytes (e.g. via code execution), downscale large images to ' + '~1024px JPEG first, base64-encode, and send. For anything over ~40KB, send multiple calls: the first ' + 'with filename, each following chunk with append_to_path set to the returned path.', inputSchema: { type: 'object', properties: { data: str('Base64-encoded file bytes for this chunk (keep each chunk under ~64KB of base64)'), filename: str('Filename with a media extension for a NEW import, e.g. "photo.jpg"'), append_to_path: str('Path returned by the previous call — appends this chunk to that file instead of creating a new one'), }, required: ['data'], }, local: true, // handled inside the server (import-media.mjs); never spawns the CLI }, { name: 'list_media', description: 'List recent files in the Sogni media inbox: media the user sent via connected messaging apps ' + 'plus files saved by import_media. Chat attachments do NOT appear here unless imported first.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['images', 'audio', 'all'] } }, }, buildArgs(input) { return ['--json', '--no-update-check', '--list-media', input.type ?? 'images']; }, }, { name: 'manage_personas', description: 'Manage saved personas (named people with reference photos and optional voice clips). ' + 'Actions: list, add (needs name + ref photo), remove, resolve (show details). ' + CHAT_ATTACHMENT_NOTE, inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'add', 'remove', 'resolve'] }, name: str('Persona name (add/remove/resolve)'), ref: str('Reference photo path or URL (add)'), relationship: { type: 'string', enum: ['self', 'partner', 'child', 'friend', 'pet'] }, description: str('Appearance description (add)'), voice_clip: str('Voice clip audio path (add)'), }, required: ['action'], }, buildArgs(input) { const action = required(input, 'action'); if (action === 'list') return ['--json', '--no-update-check', '--persona-list']; const name = required(input, 'name'); if (action === 'remove') return ['--no-update-check', '--persona-remove', String(name)]; if (action === 'resolve') return ['--json', '--no-update-check', '--persona-resolve', String(name)]; if (action === 'add') { const ref = required(input, 'ref', 'a reference photo for the persona'); const args = ['--no-update-check', '--persona-add', String(name), '--ref', String(ref)]; push(args, '--relationship', input.relationship); push(args, '--description', input.description); push(args, '--voice-clip', input.voice_clip); return args; } throw new Error(`Unknown action "${action}".`); }, }, { name: 'manage_memories', description: 'Manage persistent user preferences (e.g. preferred_style). Actions: list, get, set, remove.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'get', 'set', 'remove'] }, key: str('Memory key (get/set/remove)'), value: str('Memory value (set)'), category: { type: 'string', enum: ['preference', 'fact', 'context'] }, }, required: ['action'], }, buildArgs(input) { const action = required(input, 'action'); if (action === 'list') return ['--json', '--no-update-check', '--memory-list']; const key = required(input, 'key'); if (action === 'get') return ['--json', '--no-update-check', '--memory-get', String(key)]; if (action === 'remove') return ['--no-update-check', '--memory-remove', String(key)]; if (action === 'set') { const value = required(input, 'value'); const args = ['--no-update-check', '--memory-set', String(key), String(value)]; push(args, '--memory-category', input.category); return args; } throw new Error(`Unknown action "${action}".`); }, }, { name: 'sogni_doctor', description: 'Health check for the Sogni install: Node, credentials, ffmpeg, live auth, version. ' + 'Run only after another tool fails — not as a routine preflight.', inputSchema: { type: 'object', properties: {} }, buildArgs() { return ['--doctor', '--json', '--no-update-check']; }, }, { name: 'account_balance', description: 'Show the SPARK/SOGNI token balances for the configured Sogni account.', inputSchema: { type: 'object', properties: {} }, buildArgs() { return ['--balances', '--json', '--no-update-check']; }, }, ]; export function getTool(name) { return TOOLS.find((t) => t.name === name); }