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 }; }