8f91c52c67
Introduce help escalation persistence, cursor help worker processing, optional OpenAI-compatible chat bridge, and page delivery remediation. Co-authored-by: Cursor <cursoragent@cursor.com>
234 lines
7.3 KiB
JavaScript
234 lines
7.3 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|