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,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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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=<uuid> --session-id=<goose-session> --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=<uuid> --session-id=<session> --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);
|
||||
});
|
||||
Reference in New Issue
Block a user