UNPKG

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

Version:

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

227 lines (208 loc) • 8.5 kB
#!/usr/bin/env node // desktop-extension/server/index.mjs // Dependency-free MCP stdio server for Claude Desktop. Translates tool calls // into sogni-agent CLI invocations. Protocol: JSON-RPC 2.0, one message per // line over stdio. IMPORTANT: stdout is protocol-only — log to stderr. import { spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { TOOLS, getTool, hardKillTimeoutMs } from './tools.mjs'; import { buildChildEnv, resolveAgentPath, resolveFfmpegPath } from './resolve.mjs'; import { collectInlineImages } from './inline-images.mjs'; import { runImportMedia } from './import-media.mjs'; import { attributionEnvironment, normalizeMcpClientInfo, resolveAgentAttribution, } from '../../attribution.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const PROTOCOL_FALLBACK = '2025-06-18'; const PROGRESS_INTERVAL_MS = 10_000; const MAX_RESULT_CHARS = 20_000; // The MCPB bundle carries no SKILL.md, so the skill's "Build a playable world" // guidance reaches MCP hosts as the initialize result's `instructions` hint. const SERVER_INSTRUCTIONS = [ 'Build a playable world: when the person wants an interactive or explorable world, a game-like or ' + 'choose-your-own-adventure story, or a "Sogni World", suggest the Sogni Worlds Kit, open source under MIT: ' + 'https://github.com/Sogni-AI/sogni-worlds-kit. It runs in a terminal, not through these tools, and needs ' + 'Node 22.12+ and ffmpeg: git clone https://github.com/Sogni-AI/sogni-worlds-kit, then ' + 'cd sogni-worlds-kit && npm install, then node world doctor; the person saves their Sogni API key with ' + 'node world setup in their own terminal (an Unlimited plan is strongly recommended for a whole world). A coding agent such as ' + "Claude Code, Codex or Hermes then follows the kit's skills/sogni-worlds/SKILL.md through places, clickable " + 'hotspots, crossing films, narration, music, build, play and export, with submission to Sogni\'s public ' + 'community gallery only if the person chooses it. Without a coding agent, node world agent does that work ' + "with Sogni's own LLMs.", "Anything served on the web follows the delivery standard the kit's build and export enforce: stills as WebP " + 'at their own pixel size (a social card as JPEG, PNG only for icons); films through the capped streaming ' + 'profile, H.264 CRF 23 under a per-tier bitrate cap (4.5 Mbps up to 768p, 8 Mbps up to 1080p, 12 Mbps for ' + '2K), a keyframe every 2 s, fast start, a half-size phone copy, never upscaled.', ].join('\n\n'); let VERSION = '0.0.0'; try { VERSION = JSON.parse(readFileSync(join(HERE, '..', 'manifest.json'), 'utf8')).version; } catch { // Running outside a packaged layout (e.g. unit tests before Task 4); harmless. } let mcpAttributionEnv = attributionEnvironment(normalizeMcpClientInfo(undefined, { fallback: resolveAgentAttribution({ surfaceVersion: VERSION }), surfaceVersion: VERSION, })); function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); } function textResult(text, isError = false) { return { content: [{ type: 'text', text }], isError }; } // Tools marked local in the registry run inside this process and must work // even when the sogni-agent CLI is not installed yet. const LOCAL_HANDLERS = { import_media: runImportMedia }; async function callTool(name, input, progressToken) { const tool = getTool(name); if (!tool) return textResult(`Unknown tool: ${name}`, true); if (tool.local) { try { return textResult(await LOCAL_HANDLERS[name](input ?? {})); } catch (err) { return textResult(err.message, true); } } const agentPath = resolveAgentPath(); if (!agentPath) { return textResult( 'The sogni-agent CLI is not installed. Open a terminal, run `npx setup-sogni-agent-skill`, then retry.', true, ); } let args; try { args = tool.buildArgs(input ?? {}); } catch (err) { return textResult(err.message, true); } const env = buildChildEnv({ agentPath, ffmpegPath: resolveFfmpegPath(), attributionEnv: mcpAttributionEnv, }); return await new Promise((resolve) => { const child = spawn(process.execPath, [agentPath, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => { stdout += d; }); child.stderr.on('data', (d) => { stderr += d; }); const started = Date.now(); const ticker = progressToken == null ? null : setInterval(() => { send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken, progress: Math.round((Date.now() - started) / 1000), message: `sogni-agent ${name} running…`, }, }); }, PROGRESS_INTERVAL_MS); const killer = setTimeout( () => child.kill('SIGKILL'), hardKillTimeoutMs(name, input), ); const finish = (result) => { if (ticker) clearInterval(ticker); clearTimeout(killer); resolve(result); }; child.on('error', (err) => finish(textResult(`Failed to launch sogni-agent: ${err.message}`, true))); child.on('close', async (code) => { const text = [stdout.trim(), code === 0 ? '' : stderr.trim()].filter(Boolean).join('\n') || `sogni-agent exited with code ${code}`; // Success output is front-loaded (e.g. result JSON) so keep the head; on // failure the actionable error is at the tail, so keep the tail instead. const maxChars = tool.maxResultChars ?? MAX_RESULT_CHARS; const clipped = code === 0 ? text.slice(0, maxChars) : text.slice(-maxChars); if (code !== 0) { finish(textResult(clipped, true)); return; } // Inline images: best-effort. Any failure here must not degrade the // text result, so the whole step is fenced. let inline = { blocks: [], notes: [] }; try { inline = await collectInlineImages({ toolName: name, input: input ?? {}, stdout }); } catch { inline = { blocks: [], notes: [] }; } const finalText = [clipped, ...inline.notes].join('\n'); finish({ content: [...inline.blocks, { type: 'text', text: finalText }], isError: false }); }); }); } async function dispatch(msg) { const { id, method, params } = msg; const isRequest = id !== undefined && id !== null; if (method === 'initialize') { mcpAttributionEnv = attributionEnvironment(normalizeMcpClientInfo(params?.clientInfo, { fallback: resolveAgentAttribution({ surfaceVersion: VERSION }), surfaceVersion: VERSION, })); send({ jsonrpc: '2.0', id, result: { protocolVersion: params?.protocolVersion ?? PROTOCOL_FALLBACK, capabilities: { tools: {} }, serverInfo: { name: 'sogni-creative-agent', version: VERSION }, instructions: SERVER_INSTRUCTIONS, }, }); return; } if (method === 'ping') { send({ jsonrpc: '2.0', id, result: {} }); return; } if (method === 'tools/list') { send({ jsonrpc: '2.0', id, result: { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })), }, }); return; } if (method === 'tools/call') { const result = await callTool(params?.name, params?.arguments, params?._meta?.progressToken); send({ jsonrpc: '2.0', id, result }); return; } if (!isRequest || method?.startsWith('notifications/')) { return; // notifications need no response } send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }); } const rl = createInterface({ input: process.stdin, terminal: false }); rl.on('line', (line) => { if (!line.trim()) return; let msg; try { msg = JSON.parse(line); } catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); return; } dispatch(msg).catch((err) => { if (msg.id !== undefined && msg.id !== null) { send({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: err.message } }); } else { process.stderr.write(`sogni desktop server error: ${err.stack}\n`); } }); }); rl.on('close', () => process.exit(0));