From 8f91c52c671ece628089e9f6a882abe12b9e37f1 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 27 Aug 2026 09:33:49 +0800 Subject: [PATCH] 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 --- cursor-help-worker.mjs | 171 ++++++++ cursor-openai-bridge.mjs | 233 ++++++++++ help-escalation.mjs | 521 +++++++++++++++++++++++ help-escalation.test.mjs | 96 +++++ help-page-delivery-remediation.mjs | 82 ++++ scripts/cursor-help-worker.mjs | 98 +++++ scripts/help-remediate-page-delivery.mjs | 189 ++++++++ 7 files changed, 1390 insertions(+) create mode 100644 cursor-help-worker.mjs create mode 100644 cursor-openai-bridge.mjs create mode 100644 help-escalation.mjs create mode 100644 help-escalation.test.mjs create mode 100644 help-page-delivery-remediation.mjs create mode 100644 scripts/cursor-help-worker.mjs create mode 100644 scripts/help-remediate-page-delivery.mjs diff --git a/cursor-help-worker.mjs b/cursor-help-worker.mjs new file mode 100644 index 0000000..78961ec --- /dev/null +++ b/cursor-help-worker.mjs @@ -0,0 +1,171 @@ +import { + HELP_ESCALATION_STATUS, + createHelpEscalationService, + deliverHelpEscalationResult, + parseHelpEscalationAgentResult, + resolveCursorHelpAgentBin, + resolveCursorHelpWorkspace, + runCursorHelpAgent, +} from './help-escalation.mjs'; +import { + runHelpPageDeliveryRemediationScript, + shouldAutoRemediateHelpViaGoose, +} from './help-page-delivery-remediation.mjs'; + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function isCursorHelpWorkerEnabled(env = process.env) { + return envFlag(env.MEMIND_CURSOR_HELP_WORKER_ENABLED, false); +} + +export async function processOneHelpEscalation({ + helpEscalationService, + scheduleService = null, + sendWechatTextToUser = null, + logger = console, + env = process.env, + workerId = 'cursor-help-worker', +} = {}) { + const escalation = await helpEscalationService.claimNext(workerId); + if (!escalation) return null; + + const workspace = resolveCursorHelpWorkspace(env, helpEscalationService.memindRoot); + const agentBin = resolveCursorHelpAgentBin(env); + const prompt = helpEscalationService.buildPrompt(escalation); + + try { + const { stdout, stderr } = await runCursorHelpAgent({ + prompt, + workspace, + agentBin, + env, + logger, + }); + const parsed = parseHelpEscalationAgentResult(stdout); + let resultText = parsed.userMessage; + let agentOutput = stdout; + + if ( + parsed.status === HELP_ESCALATION_STATUS.SUCCEEDED + && shouldAutoRemediateHelpViaGoose(escalation, env) + ) { + try { + const remediated = await runHelpPageDeliveryRemediationScript({ + escalation, + env, + logger, + viaGoose: true, + }); + if (remediated?.userMessage) { + resultText = remediated.userMessage; + agentOutput = `${stdout}\n\n--- auto goose remediation ---\n${JSON.stringify(remediated)}`; + } + } catch (remediationError) { + logger.warn?.( + '[cursor-help] auto goose remediation failed:', + remediationError instanceof Error ? remediationError.message : remediationError, + ); + } + } + + const completed = await helpEscalationService.complete(escalation.id, { + status: parsed.status, + resultText, + agentOutput, + errorMessage: parsed.status === HELP_ESCALATION_STATUS.FAILED + ? stderr || parsed.userMessage + : null, + }); + await deliverHelpEscalationResult({ + escalation: completed, + scheduleService, + sendWechatTextToUser, + logger, + }); + return completed; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const completed = await helpEscalationService.complete(escalation.id, { + status: HELP_ESCALATION_STATUS.FAILED, + resultText: '工程师处理失败,请稍后重试或联系平台管理员。', + agentOutput: error?.stdout ?? null, + errorMessage: message, + }); + await deliverHelpEscalationResult({ + escalation: completed, + scheduleService, + sendWechatTextToUser, + logger, + }); + return completed; + } +} + +export function startCursorHelpWorker({ + pool, + scheduleService = null, + sendWechatTextToUser = null, + logger = console, + env = process.env, + pollMs = Number(env.MEMIND_CURSOR_HELP_POLL_MS ?? 2000), + runOnStart = true, +} = {}) { + const helpEscalationService = createHelpEscalationService({ pool, logger, env }); + if (!helpEscalationService.enabled || !isCursorHelpWorkerEnabled(env)) { + return { stop() {}, helpEscalationService }; + } + + let stopped = false; + let running = false; + let timer = null; + + const tick = async () => { + if (stopped || running) return; + running = true; + try { + await processOneHelpEscalation({ + helpEscalationService, + scheduleService, + sendWechatTextToUser, + logger, + env, + }); + } catch (error) { + logger.error?.('[cursor-help] worker tick failed:', error); + } finally { + running = false; + } + }; + + timer = setInterval(() => { + void tick(); + }, pollMs); + timer.unref?.(); + + if (runOnStart) { + void tick(); + } + + logger.log?.('[cursor-help] worker enabled'); + + return { + helpEscalationService, + stop() { + stopped = true; + if (timer) clearInterval(timer); + }, + async processOnce() { + return processOneHelpEscalation({ + helpEscalationService, + scheduleService, + sendWechatTextToUser, + logger, + env, + }); + }, + }; +} diff --git a/cursor-openai-bridge.mjs b/cursor-openai-bridge.mjs new file mode 100644 index 0000000..20c7250 --- /dev/null +++ b/cursor-openai-bridge.mjs @@ -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(); +} diff --git a/help-escalation.mjs b/help-escalation.mjs new file mode 100644 index 0000000..9742007 --- /dev/null +++ b/help-escalation.mjs @@ -0,0 +1,521 @@ +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 }; +} diff --git a/help-escalation.test.mjs b/help-escalation.test.mjs new file mode 100644 index 0000000..9928d45 --- /dev/null +++ b/help-escalation.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + HELP_ESCALATION_ACK_TEXT, + HELP_ESCALATION_DETAILS_PROMPT, + HELP_ESCALATION_STATUS, + buildHelpEscalationPrompt, + extractHelpEscalationProblemText, + extractHelpEscalationPagePath, + extractHelpEscalationText, + hasHelpEscalationProblemDescription, + isHelpEscalationIntent, + normalizeHelpEscalationCommandText, + parseHelpEscalationAgentResult, +} from './help-escalation.mjs'; + +test('isHelpEscalationIntent matches help commands', () => { + assert.equal(isHelpEscalationIntent('help 我的页面打不开'), true); + assert.equal(isHelpEscalationIntent('/help'), true); + assert.equal(isHelpEscalationIntent('帮助 定时任务没推送'), true); + assert.equal(isHelpEscalationIntent('help,页面打不开'), true); + assert.equal(isHelpEscalationIntent('please help me build a page'), false); + assert.equal(isHelpEscalationIntent('hello'), false); +}); + +test('hasHelpEscalationProblemDescription requires concrete problem text', () => { + assert.equal(hasHelpEscalationProblemDescription('help'), false); + assert.equal(hasHelpEscalationProblemDescription('/help'), false); + assert.equal(hasHelpEscalationProblemDescription('帮助'), false); + assert.equal(hasHelpEscalationProblemDescription('help 页面 409'), true); + assert.equal(extractHelpEscalationProblemText('help,页面打不开'), '页面打不开'); + assert.match(HELP_ESCALATION_DETAILS_PROMPT, /补充具体问题/); +}); + +test('isHelpEscalationIntent matches help after H5 identity prefix', () => { + const prefixed = [ + '[用户身份]', + '- 当前登录用户称呼:John', + '', + 'help 这个链接无法打开 http://127.0.0.1:5173/foo.html', + ].join('\n'); + assert.equal(isHelpEscalationIntent(prefixed), true); + assert.equal( + normalizeHelpEscalationCommandText(prefixed), + 'help 这个链接无法打开 http://127.0.0.1:5173/foo.html', + ); +}); + +test('extractHelpEscalationText reads goose message content', () => { + assert.equal( + extractHelpEscalationText({ + content: [{ type: 'text', text: 'help 页面 409' }], + }), + 'help 页面 409', + ); +}); + +test('buildHelpEscalationPrompt includes ticket context', () => { + const prompt = buildHelpEscalationPrompt({ + id: 'esc-1', + userId: 'user-1', + channel: 'wechat_mp', + userText: 'help 页面打不开', + context: { agentSessionId: 'sess-1', openid: 'wx-1' }, + }, { memindRoot: '/tmp/Memind' }); + assert.match(prompt, /esc-1/); + assert.match(prompt, /help 页面打不开/); + assert.match(prompt, /sess-1/); +}); + +test('parseHelpEscalationAgentResult reads trailing json', () => { + const parsed = parseHelpEscalationAgentResult([ + '已修复 delivery 契约并补发链接。', + '{"status":"succeeded","userMessage":"你的新闻页已恢复,请重新打开链接。"}', + ].join('\n')); + assert.equal(parsed.status, HELP_ESCALATION_STATUS.SUCCEEDED); + assert.match(parsed.userMessage, /已恢复/); +}); + +test('parseHelpEscalationAgentResult falls back to stdout text', () => { + const parsed = parseHelpEscalationAgentResult('已重启 goosed 并恢复 Portal。'); + assert.equal(parsed.status, HELP_ESCALATION_STATUS.SUCCEEDED); + assert.match(parsed.userMessage, /重启 goosed/); +}); + +test('extractHelpEscalationPagePath reads public html path', () => { + assert.equal( + extractHelpEscalationPagePath('help 链接失败 public/spring-poem.html', {}), + 'public/spring-poem.html', + ); + assert.equal( + extractHelpEscalationPagePath('help', { relativePath: 'public/tang-dynasty.html' }), + 'public/tang-dynasty.html', + ); +}); diff --git a/help-page-delivery-remediation.mjs b/help-page-delivery-remediation.mjs new file mode 100644 index 0000000..f20624e --- /dev/null +++ b/help-page-delivery-remediation.mjs @@ -0,0 +1,82 @@ +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { + extractHelpEscalationPagePath, + extractHelpEscalationSessionId, +} from './help-escalation.mjs'; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url))); + +function envFlag(value, fallback = true) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +export function shouldAutoRemediateHelpViaGoose(escalation, env = process.env) { + if (!envFlag(env.MEMIND_CURSOR_HELP_AUTO_GOOSE_REMEDIATE, true)) return false; + const pagePath = extractHelpEscalationPagePath(escalation?.userText, escalation?.context ?? {}); + return Boolean(pagePath && escalation?.userId); +} + +export function runHelpPageDeliveryRemediationScript({ + escalation, + env = process.env, + logger = console, + viaGoose = true, +} = {}) { + const userId = escalation?.userId; + const sessionId = extractHelpEscalationSessionId(escalation); + const pagePath = extractHelpEscalationPagePath(escalation?.userText, escalation?.context ?? {}); + if (!userId || !pagePath) { + return Promise.resolve(null); + } + + const scriptPath = path.join(repoRoot, 'scripts/help-remediate-page-delivery.mjs'); + const args = [ + scriptPath, + `--user-id=${userId}`, + `--page=${pagePath}`, + `--port=${Number(env.H5_PORT ?? 8081)}`, + ]; + if (sessionId) args.push(`--session-id=${sessionId}`); + if (viaGoose && sessionId) args.push('--via-goose'); + + logger.info?.('[cursor-help] auto goose remediation', { + userId, + sessionId, + pagePath, + viaGoose: Boolean(viaGoose && sessionId), + }); + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + cwd: repoRoot, + stdio: ['ignore', 'pipe', 'pipe'], + env, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(stderr.trim() || stdout.trim() || `remediation exited ${code}`)); + return; + } + try { + const jsonLine = stdout.trim().split('\n').reverse().find((line) => line.startsWith('{')); + resolve(jsonLine ? JSON.parse(jsonLine) : { stdout }); + } catch { + resolve({ stdout }); + } + }); + }); +} diff --git a/scripts/cursor-help-worker.mjs b/scripts/cursor-help-worker.mjs new file mode 100644 index 0000000..6c05c3f --- /dev/null +++ b/scripts/cursor-help-worker.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDbPool } from '../db.mjs'; +import { + createHelpEscalationService, + isHelpEscalationIntent, +} from '../help-escalation.mjs'; +import { + isCursorHelpWorkerEnabled, + processOneHelpEscalation, + startCursorHelpWorker, +} from '../cursor-help-worker.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +function parseArgs(argv) { + const args = { once: false, status: false, help: false }; + for (const item of argv) { + if (item === '--once') args.once = true; + else if (item === '--status') args.status = true; + else if (item === '--help' || item === '-h') args.help = true; + } + return args; +} + +function printHelp() { + console.log([ + 'Usage:', + ' node scripts/cursor-help-worker.mjs [--once] [--status]', + '', + 'Environment:', + ' MEMIND_CURSOR_HELP_ENABLED=1', + ' MEMIND_CURSOR_HELP_WORKER_ENABLED=1', + ' MEMIND_CURSOR_HELP_WORKSPACE=/Users/john/Project/Memind', + ' MEMIND_CURSOR_HELP_AGENT_BIN=~/.local/bin/agent', + ' MEMIND_CURSOR_HELP_POLL_MS=2000', + ].join('\n')); +} + +loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(root, '.env')); +loadEnvFile(path.join(root, '.env.local')); + +const args = parseArgs(process.argv.slice(2)); +if (args.help) { + printHelp(); + process.exit(0); +} + +const pool = createDbPool(); +const helpEscalationService = createHelpEscalationService({ pool }); + +if (args.status) { + const stats = await helpEscalationService.getQueueStats(); + console.log(JSON.stringify({ + ok: true, + enabled: helpEscalationService.enabled, + workerEnabled: isCursorHelpWorkerEnabled(), + helpIntentSample: isHelpEscalationIntent('help 我的页面打不开'), + stats, + }, null, 2)); + process.exit(0); +} + +if (args.once) { + const result = await processOneHelpEscalation({ helpEscalationService }); + console.log(JSON.stringify({ ok: true, processed: Boolean(result), result }, null, 2)); + process.exit(0); +} + +const worker = startCursorHelpWorker({ pool }); +if (!worker.helpEscalationService?.enabled || !isCursorHelpWorkerEnabled()) { + console.error('Cursor help worker is disabled. Set MEMIND_CURSOR_HELP_ENABLED=1 and MEMIND_CURSOR_HELP_WORKER_ENABLED=1'); + process.exit(1); +} + +process.on('SIGINT', () => { + worker.stop(); + process.exit(0); +}); +process.on('SIGTERM', () => { + worker.stop(); + process.exit(0); +}); diff --git a/scripts/help-remediate-page-delivery.mjs b/scripts/help-remediate-page-delivery.mjs new file mode 100644 index 0000000..ccf1aab --- /dev/null +++ b/scripts/help-remediate-page-delivery.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Help escalation remediation: fix delivery contract + notify user + optional Goose resend. + * + * Usage: + * node scripts/help-remediate-page-delivery.mjs \ + * --user-id= --session-id= --page=public/foo.html [--via-goose] + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { createDbPool } from '../db.mjs'; +import { + getPageDeliveryContract, + normalizeDeliveryRelativePath, + releaseMaterializedPageDeliveryContracts, +} from '../mindspace-delivery-contract.mjs'; +import { createMindSpacePublicPageUrl } from '../mindspace-canonical-url.mjs'; +import { createScheduleService } from '../schedule-service.mjs'; +import { PUBLISH_ROOT_DIR, resolvePageDataDeliveryBaseUrl } from '../user-publish.mjs'; +import { loadH5Environment } from './load-env.mjs'; +import { + createAgentRun, + extractAssistantTexts, + extractPublicLinks, + getSession, + loginViaApi, + resolvePortalBase, + waitForAssistantGrowth, + waitForRunTerminal, +} from './scenario-test-lib.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +loadH5Environment(path.dirname(fileURLToPath(import.meta.url))); + +function parseArgs(argv) { + const options = { + userId: '', + sessionId: '', + page: '', + viaGoose: false, + port: Number(process.env.H5_PORT ?? 8081), + username: process.env.RELEASE_GATE_SCENARIO_USERNAME ?? 'john', + password: process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888', + }; + for (const arg of argv) { + if (arg.startsWith('--user-id=')) options.userId = arg.slice('--user-id='.length).trim(); + else if (arg.startsWith('--session-id=')) options.sessionId = arg.slice('--session-id='.length).trim(); + else if (arg.startsWith('--page=')) options.page = arg.slice('--page='.length).trim(); + else if (arg.startsWith('--port=')) options.port = Number(arg.slice('--port='.length)); + else if (arg === '--via-goose') options.viaGoose = true; + else if (arg === '--help' || arg === '-h') { + console.log(`Usage: node scripts/help-remediate-page-delivery.mjs \\ + --user-id= --session-id= --page=public/foo.html [--via-goose]`); + process.exit(0); + } + } + return options; +} + +function pageTitleFromPath(relativePath) { + const base = path.basename(relativePath, '.html'); + return base.replace(/[-_]+/g, ' ').trim() || '页面'; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const relativePath = normalizeDeliveryRelativePath(options.page); + if (!options.userId || !relativePath) { + console.error('缺少 --user-id 或有效 --page=public/xxx.html'); + process.exit(1); + } + + const publishDir = path.join(root, PUBLISH_ROOT_DIR, options.userId); + const htmlPath = path.join(publishDir, relativePath); + if (!fs.existsSync(htmlPath)) { + console.error(`页面文件不存在: ${htmlPath}`); + process.exit(1); + } + + const pool = createDbPool(); + const scheduleService = createScheduleService(pool, { + defaultTimezone: process.env.MEMIND_DEFAULT_TIMEZONE ?? 'Asia/Shanghai', + }); + + try { + const released = await releaseMaterializedPageDeliveryContracts({ + pool, + userId: options.userId, + relativePaths: [relativePath], + publishDir, + }); + const contract = await getPageDeliveryContract({ + pool, + userId: options.userId, + relativePath, + }); + + const publicBaseUrl = resolvePageDataDeliveryBaseUrl(process.env); + const pageUrl = createMindSpacePublicPageUrl({ + publicBaseUrl, + ownerKey: options.userId, + filename: relativePath.replace(/^public\//, ''), + }); + const pageTitle = pageTitleFromPath(relativePath); + const userMessage = [ + '工程师已修复页面交付,请打开链接:', + `[${pageTitle}](${pageUrl})`, + '', + `页面地址:${pageUrl}`, + ].join('\n'); + + let gooseReply = null; + if (options.viaGoose && options.sessionId) { + const baseUrl = resolvePortalBase(options.port); + const reporter = { pass() {}, fail() {} }; + const auth = await loginViaApi(baseUrl, { + username: options.username, + password: options.password, + }, reporter); + if (auth.user?.id && auth.user.id !== options.userId) { + console.warn('[help-remediate] 登录用户与 --user-id 不一致,仍继续'); + } + + const before = await getSession(baseUrl, auth.cookie, options.sessionId); + const beforeTexts = before.ok ? extractAssistantTexts(before.session) : []; + const followUpText = '请把刚才生成的诗歌 HTML 页面链接发给我'; + const run = await createAgentRun(baseUrl, auth.cookie, { + message: followUpText, + sessionId: options.sessionId, + }); + const terminal = await waitForRunTerminal( + baseUrl, + auth.cookie, + run.runId, + Number(process.env.MEMIND_HELP_REMEDIATE_GOOSE_TIMEOUT_MS ?? 600_000), + ); + if (terminal.status !== 'succeeded') { + throw new Error(`Goose 补救 run 失败: ${terminal.error ?? terminal.status}`); + } + const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, options.sessionId, { + previousCount: beforeTexts.length, + previousCombinedLength: beforeTexts.join('\n').length, + minChars: 5, + timeoutMs: 120_000, + }); + gooseReply = reply?.combined ?? null; + const links = extractPublicLinks(gooseReply ?? '', baseUrl); + if (!links.some((url) => url.includes(relativePath.replace(/^public\//, '')))) { + console.warn('[help-remediate] Goose 回复未含预期页面链接,将依赖通知补发'); + } + } + + await scheduleService.createUserNotification({ + userId: options.userId, + channel: 'web', + notificationType: 'help_escalation_result', + title: '工程师已补发页面链接', + body: userMessage, + data: { + pageUrl, + relativePath, + sessionId: options.sessionId || null, + remediatedBy: 'help-remediate-page-delivery', + }, + }); + + const result = { + status: 'succeeded', + userMessage: `页面链接已修复并补发。\n\n${userMessage}`, + pageUrl, + relativePath, + contractStatus: contract?.status ?? null, + releasedPaths: released, + gooseReplyPreview: gooseReply ? gooseReply.slice(0, 400) : null, + }; + console.log(JSON.stringify(result, null, 2)); + } finally { + await pool.end(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack ?? error.message : error); + process.exit(1); +});