Files
memind/help-escalation.mjs
T
john 8f91c52c67 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>
2026-08-27 09:33:49 +08:00

522 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { normalizeDeliveryRelativePath } from './mindspace-delivery-contract.mjs';
import {
expandHome,
resolveCursorAgentBin as resolveSharedCursorAgentBin,
} from './cursor-agent-launch.mjs';
export const HELP_ESCALATION_ACK_TEXT =
'已转工程师处理,请稍候。处理完成后会通知你结果。';
export const HELP_ESCALATION_DETAILS_PROMPT = [
'请补充具体问题后再发送,例如:',
'help 我的页面 tang-dynasty 打不开',
'help 聊天一直卡住',
'help 定时任务没有推送',
'',
'工程师需要知道:出了什么问题、相关页面或链接、你期望的结果。',
].join('\n');
export const HELP_ESCALATION_STATUS = {
QUEUED: 'queued',
RUNNING: 'running',
SUCCEEDED: 'succeeded',
FAILED: 'failed',
};
const HELP_INTENT_PATTERNS = [
/^help(?:\s+|$)/i,
/^\/help(?:\s+|$)/i,
/^帮助(?:\s+|$)/u,
/^人工帮助(?:\s+|$)/u,
/^找工程师(?:\s+|$)/u,
/^升级处理(?:\s+|$)/u,
/^工程师介入(?:\s+|$)/u,
];
const HELP_COMMAND_PREFIX_PATTERNS = [
/^help[,]?\s*/i,
/^\/help\s*/i,
/^帮助\s*/u,
/^人工帮助\s*/u,
/^找工程师\s*/u,
/^升级处理\s*/u,
/^工程师介入\s*/u,
];
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function nowMs() {
return Date.now();
}
export function extractHelpEscalationText(input) {
if (typeof input === 'string') return input.trim();
if (!input || typeof input !== 'object') return '';
if (typeof input.text === 'string') return input.text.trim();
if (Array.isArray(input.content)) {
return input.content
.map((item) => {
if (!item || typeof item !== 'object') return '';
if (item.type === 'text') return String(item.text ?? '').trim();
return '';
})
.filter(Boolean)
.join('\n')
.trim();
}
return String(input.value ?? '').trim();
}
export function normalizeHelpEscalationCommandText(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return '';
const blocks = normalized.split(/\n\n+/);
const lastBlock = (blocks[blocks.length - 1] ?? normalized).trim();
const candidate = lastBlock || normalized;
return candidate.replace(/^help[,]\s*/i, 'help ');
}
export function isHelpEscalationIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
const commandText = normalizeHelpEscalationCommandText(normalized);
for (const candidate of [normalized, commandText]) {
if (!candidate) continue;
if (HELP_INTENT_PATTERNS.some((pattern) => pattern.test(candidate))) {
return true;
}
}
return false;
}
export function extractHelpEscalationProblemText(text) {
const commandText = normalizeHelpEscalationCommandText(text);
if (!commandText) return '';
for (const pattern of HELP_COMMAND_PREFIX_PATTERNS) {
if (!pattern.test(commandText)) continue;
return commandText.replace(pattern, '').trim();
}
return commandText.trim();
}
export function hasHelpEscalationProblemDescription(text) {
return extractHelpEscalationProblemText(text).length >= 2;
}
const HELP_PAGE_PATH_PATTERN = /\b(public\/[^\s"'<>]+\.html)\b/i;
export function extractHelpEscalationPagePath(userText, context = {}) {
const rawContextPath = context.relativePath ?? context.pagePath ?? context.workspaceRelativePath ?? '';
const fromContext = normalizeDeliveryRelativePath(rawContextPath);
if (fromContext) return fromContext;
const text = String(userText ?? '');
const match = text.match(HELP_PAGE_PATH_PATTERN);
return match?.[1] ? match[1].replace(/\\/g, '/').trim() : null;
}
export function extractHelpEscalationSessionId(escalation) {
const context = escalation?.context ?? {};
return String(
context.sessionId
?? context.agentSessionId
?? escalation?.agentSessionId
?? '',
).trim() || null;
}
function parseJsonSafe(value, fallback = null) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function rowToEscalation(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
channel: row.channel,
status: row.status,
userText: row.user_text,
context: parseJsonSafe(row.context_json, {}),
agentSessionId: row.agent_session_id,
resultText: row.result_text,
agentOutput: row.agent_output,
errorMessage: row.error_message,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
startedAt: row.started_at == null ? null : Number(row.started_at),
completedAt: row.completed_at == null ? null : Number(row.completed_at),
};
}
export function buildHelpEscalationPrompt(escalation, { memindRoot = process.cwd() } = {}) {
const context = escalation?.context ?? {};
const sessionId = context.sessionId ?? context.agentSessionId ?? escalation.agentSessionId ?? null;
return [
'你是 Memind 平台值班工程师。用户通过 help 指令请求人工介入,请在本机直接排查并修复问题。',
'',
'要求:',
'1. 工作目录是 Memind 仓库,可以直接修改文件、运行脚本、重启相关服务。',
'2. 优先修复用户问题,不要只做分析。',
'3. 页面/链接类问题:必须先修复 delivery contract,再通过 Goose 原会话把链接补发给用户(禁止只改库不通知)。',
'4. 页面链接补救优先运行(把参数换成工单上下文中的真实值):',
' node scripts/help-remediate-page-delivery.mjs \\',
' --user-id=<用户ID> \\',
' --session-id=<会话ID> \\',
' --page=public/<页面文件名>.html \\',
' --via-goose',
'5. 若用户描述不清,可在 userMessage 里继续引导其补充;问题已解决则明确告知链接与下一步。',
'6. 完成后用中文给出简短结果,说明做了什么、用户接下来怎么用。',
'7. 最后一行单独输出 JSON{"status":"succeeded|failed","userMessage":"给用户的中文回复"}',
'',
`Memind 根目录: ${memindRoot}`,
`工单 ID: ${escalation.id}`,
`用户 ID: ${escalation.userId}`,
`渠道: ${escalation.channel}`,
`用户原文: ${escalation.userText}`,
`会话 ID: ${sessionId ?? '无'}`,
`OpenID: ${context.openid ?? '无'}`,
`Request ID: ${context.requestId ?? '无'}`,
'',
'相关上下文 JSON:',
JSON.stringify(context, null, 2),
].join('\n');
}
export function parseHelpEscalationAgentResult(stdout) {
const text = String(stdout ?? '').trim();
if (!text) {
return {
status: HELP_ESCALATION_STATUS.FAILED,
userMessage: '工程师处理失败:Cursor 未返回结果。',
rawOutput: text,
};
}
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const candidate = parseJsonSafe(lines[index], null);
if (
candidate
&& typeof candidate === 'object'
&& typeof candidate.userMessage === 'string'
&& candidate.userMessage.trim()
) {
const status = String(candidate.status ?? '').trim().toLowerCase() === 'failed'
? HELP_ESCALATION_STATUS.FAILED
: HELP_ESCALATION_STATUS.SUCCEEDED;
return {
status,
userMessage: candidate.userMessage.trim(),
rawOutput: text,
};
}
}
return {
status: HELP_ESCALATION_STATUS.SUCCEEDED,
userMessage: text.slice(-4000),
rawOutput: text,
};
}
export function resolveCursorHelpAgentBin(env = process.env) {
return resolveSharedCursorAgentBin(env);
}
export function resolveCursorHelpWorkspace(env = process.env, fallbackRoot = process.cwd()) {
return expandHome(env.MEMIND_CURSOR_HELP_WORKSPACE ?? fallbackRoot);
}
export function runCursorHelpAgent({
prompt,
workspace,
agentBin = resolveCursorHelpAgentBin(),
env = process.env,
logger = console,
timeoutMs = Number(env.MEMIND_CURSOR_HELP_TIMEOUT_MS ?? 30 * 60 * 1000),
}) {
const args = [
'--print',
'--trust',
'--force',
'--approve-mcps',
'--output-format',
'text',
'--workspace',
workspace,
prompt,
];
logger.info?.('[cursor-help] spawning agent', {
agentBin,
workspace,
timeoutMs,
});
return new Promise((resolve, reject) => {
const child = spawn(agentBin, args, {
cwd: workspace,
env: { ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`Cursor help agent timed out after ${timeoutMs}ms`));
}, timeoutMs);
timer.unref?.();
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) {
const error = new Error(
stderr.trim() || stdout.trim() || `Cursor help agent exited with code ${code}`,
);
error.code = code;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
}
export function createHelpEscalationService({
pool,
logger = console,
env = process.env,
} = {}) {
const enabled = envFlag(env.MEMIND_CURSOR_HELP_ENABLED, false);
const memindRoot = resolveCursorHelpWorkspace(env);
async function enqueue({
userId,
channel,
userText,
context = {},
agentSessionId = null,
}) {
if (!enabled) {
throw new Error('Cursor help escalation is disabled');
}
const id = crypto.randomUUID();
const now = nowMs();
await pool.query(
`INSERT INTO h5_help_escalations (
id, user_id, channel, status, user_text, context_json, agent_session_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
userId,
channel,
HELP_ESCALATION_STATUS.QUEUED,
userText,
JSON.stringify(context ?? {}),
agentSessionId,
now,
now,
],
);
return getById(id);
}
async function getById(id) {
const [rows] = await pool.query(
'SELECT * FROM h5_help_escalations WHERE id = ? LIMIT 1',
[id],
);
return rowToEscalation(rows[0]);
}
async function claimNext(workerId = 'cursor-help-worker') {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [rows] = await connection.query(
`SELECT *
FROM h5_help_escalations
WHERE status = ?
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE`,
[HELP_ESCALATION_STATUS.QUEUED],
);
const row = rows[0];
if (!row) {
await connection.commit();
return null;
}
const now = nowMs();
await connection.query(
`UPDATE h5_help_escalations
SET status = ?, started_at = ?, updated_at = ?, error_message = NULL
WHERE id = ?`,
[HELP_ESCALATION_STATUS.RUNNING, now, now, row.id],
);
await connection.commit();
logger.info?.('[cursor-help] claimed escalation', { id: row.id, workerId });
return rowToEscalation({ ...row, status: HELP_ESCALATION_STATUS.RUNNING, started_at: now, updated_at: now });
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
async function complete(id, {
status,
resultText = null,
agentOutput = null,
errorMessage = null,
}) {
const now = nowMs();
await pool.query(
`UPDATE h5_help_escalations
SET status = ?, result_text = ?, agent_output = ?, error_message = ?,
completed_at = ?, updated_at = ?
WHERE id = ?`,
[status, resultText, agentOutput, errorMessage, now, now, id],
);
return getById(id);
}
async function getQueueStats() {
const [rows] = await pool.query(
`SELECT status, COUNT(*) AS count
FROM h5_help_escalations
GROUP BY status`,
);
const stats = {
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
};
for (const row of rows) {
stats[row.status] = Number(row.count);
}
return stats;
}
return {
enabled,
memindRoot,
enqueue,
getById,
claimNext,
complete,
getQueueStats,
buildPrompt: (escalation) => buildHelpEscalationPrompt(escalation, { memindRoot }),
};
}
export async function tryHandleHelpEscalation({
helpEscalationService,
userId,
channel,
userText,
context = {},
agentSessionId = null,
onAck = null,
}) {
if (!helpEscalationService?.enabled) return null;
const rawText = String(userText ?? '').trim();
const commandText = normalizeHelpEscalationCommandText(rawText);
if (!isHelpEscalationIntent(commandText || rawText)) return null;
if (!hasHelpEscalationProblemDescription(commandText || rawText)) {
const message = HELP_ESCALATION_DETAILS_PROMPT;
if (typeof onAck === 'function') {
await onAck(message, null);
}
return { needsDetails: true, message };
}
const escalation = await helpEscalationService.enqueue({
userId,
channel,
userText: commandText || rawText,
context,
agentSessionId,
});
if (typeof onAck === 'function') {
await onAck(HELP_ESCALATION_ACK_TEXT, escalation);
}
return escalation;
}
export async function deliverHelpEscalationResult({
escalation,
scheduleService = null,
sendWechatTextToUser = null,
logger = console,
}) {
const userMessage = String(
escalation?.resultText
?? escalation?.errorMessage
?? '工程师已处理完成,但未返回详细说明。',
).trim();
const title = escalation?.status === HELP_ESCALATION_STATUS.SUCCEEDED
? '工程师已处理完成'
: '工程师处理失败';
if (scheduleService?.createUserNotification) {
await scheduleService.createUserNotification({
userId: escalation.userId,
channel: 'web',
notificationType: 'help_escalation_result',
title,
body: userMessage,
data: {
escalationId: escalation.id,
status: escalation.status,
channel: escalation.channel,
},
}).catch((error) => {
logger.warn?.('[cursor-help] web notification failed:', error);
});
}
let wechatSent = false;
if (typeof sendWechatTextToUser === 'function') {
wechatSent = await sendWechatTextToUser(
escalation.userId,
`${title}\n${userMessage}`.trim(),
).catch((error) => {
logger.warn?.('[cursor-help] wechat notification failed:', error);
return false;
});
}
return { userMessage, wechatSent };
}