feat(help): add cursor help escalation worker and chat bridge
Introduce help escalation persistence, cursor help worker processing, optional OpenAI-compatible chat bridge, and page delivery remediation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user