/** * Experimental OpenAI-compatible bridge: goosed -> Cursor agent CLI. * * Limitations (PoC): * - No tool calls / function calling * - Streaming is best-effort single-chunk * - High latency (spawns agent per request) * * Enable: MEMIND_CURSOR_CHAT_BRIDGE_ENABLED=1 * Listen: MEMIND_CURSOR_CHAT_BRIDGE_PORT=18040 (default) */ import http from 'node:http'; import crypto from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { runCursorHelpAgent, resolveCursorHelpWorkspace, resolveCursorHelpAgentBin } from './help-escalation.mjs'; export const DEFAULT_CURSOR_CHAT_BRIDGE_PORT = 18040; export const MEMIND_CURSOR_CHAT_BRIDGE_PROVIDER_ID = 'custom_memind_cursor_chat_bridge'; function envFlag(value, fallback = false) { const raw = String(value ?? '').trim().toLowerCase(); if (!raw) return fallback; return ['1', 'true', 'yes', 'on'].includes(raw); } export function cursorChatBridgeEnabled(env = process.env) { return envFlag(env.MEMIND_CURSOR_CHAT_BRIDGE_ENABLED, false); } export function resolveCursorChatBridgeListenPort(env = process.env) { const port = Number(env.MEMIND_CURSOR_CHAT_BRIDGE_PORT ?? DEFAULT_CURSOR_CHAT_BRIDGE_PORT); return Number.isFinite(port) && port > 0 ? port : DEFAULT_CURSOR_CHAT_BRIDGE_PORT; } export function resolveCursorChatBridgeBaseUrl(env = process.env) { const explicit = String(env.MEMIND_CURSOR_CHAT_BRIDGE_BASE_URL ?? '').trim().replace(/\/$/, ''); if (explicit) return explicit; const host = String( env.MEMIND_CURSOR_CHAT_BRIDGE_HOST ?? env.MEMIND_GOOSED_HOST_GATEWAY ?? '127.0.0.1', ).trim(); return `http://${host}:${resolveCursorChatBridgeListenPort(env)}/v1`; } function readRequestBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } function buildPromptFromChatCompletions(body) { const messages = Array.isArray(body?.messages) ? body.messages : []; const lines = [ '你是 TKMind 助手,通过 Goose 聊天桥接被调用。请用中文简洁回答用户。', '不要调用工具;若用户要做页面/写文件,给出下一步建议即可。', '', ]; for (const message of messages) { const role = String(message?.role ?? 'user'); const content = typeof message?.content === 'string' ? message.content : Array.isArray(message?.content) ? message.content .map((item) => (item?.type === 'text' ? String(item.text ?? '') : '')) .filter(Boolean) .join('\n') : ''; if (!content.trim()) continue; lines.push(`${role}: ${content.trim()}`); } lines.push('assistant:'); return lines.join('\n'); } function openAiChatCompletionResponse({ model, content }) { const created = Math.floor(Date.now() / 1000); const id = `chatcmpl-cursor-${crypto.randomUUID()}`; return { id, object: 'chat.completion', created, model: model ?? 'composer-2.5', choices: [{ index: 0, message: { role: 'assistant', content: String(content ?? '').trim() }, finish_reason: 'stop', }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, }, }; } export function createCursorChatBridgeHandler({ env = process.env, logger = console } = {}) { const workspace = resolveCursorHelpWorkspace(env); const agentBin = resolveCursorHelpAgentBin(env); const timeoutMs = Number(env.MEMIND_CURSOR_CHAT_BRIDGE_TIMEOUT_MS ?? 300_000); return async function handle(req, res) { if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, bridge: 'cursor-openai' })); return; } if (req.method !== 'POST' || !/\/chat\/completions\/?$/i.test(String(req.url ?? ''))) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Not found' } })); return; } const raw = await readRequestBody(req); let body = {}; try { body = raw.length ? JSON.parse(raw.toString('utf8')) : {}; } catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Invalid JSON body' } })); return; } if (Array.isArray(body.tools) && body.tools.length > 0) { res.writeHead(501, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Cursor chat bridge PoC does not support tool calls yet; keep Goose on DeepSeek for tool turns.', }, })); return; } const prompt = buildPromptFromChatCompletions(body); logger.info?.('[cursor-chat-bridge] request', { model: body?.model ?? null, promptChars: prompt.length, }); const { stdout } = await runCursorHelpAgent({ prompt, workspace, agentBin, env: { ...env, MEMIND_CURSOR_HELP_TIMEOUT_MS: String(timeoutMs), }, logger, }); const payload = openAiChatCompletionResponse({ model: body?.model, content: stdout, }); if (body.stream === true) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); const chunk = { id: payload.id, object: 'chat.completion.chunk', created: payload.created, model: payload.model, choices: [{ index: 0, delta: { role: 'assistant', content: payload.choices[0].message.content }, finish_reason: null, }], }; res.write(`data: ${JSON.stringify(chunk)}\n\n`); res.write(`data: ${JSON.stringify({ ...chunk, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], })}\n\n`); res.write('data: [DONE]\n\n'); res.end(); return; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); }; } export function startCursorChatBridgeServer({ port = resolveCursorChatBridgeListenPort(), host = '0.0.0.0', env = process.env, logger = console, } = {}) { const handler = createCursorChatBridgeHandler({ env, logger }); const server = http.createServer((req, res) => { handler(req, res).catch((error) => { logger.error?.('[cursor-chat-bridge] failed', error instanceof Error ? error.message : error); if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'application/json' }); } res.end(JSON.stringify({ error: { message: error instanceof Error ? error.message : 'cursor chat bridge failed' }, })); }); }); return new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, () => { logger.info?.(`[cursor-chat-bridge] listening on http://${host}:${port}/v1`); resolve(server); }); }); } const isMain = Boolean( process.env.MEMIND_CURSOR_CHAT_BRIDGE_ENTRYPOINT === '1' && process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]), ); if (isMain) { if (!cursorChatBridgeEnabled()) { console.error('MEMIND_CURSOR_CHAT_BRIDGE_ENABLED is not set'); process.exit(1); } await startCursorChatBridgeServer(); }