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,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