diff --git a/.env.example b/.env.example index 3763fd7..88aafd8 100644 --- a/.env.example +++ b/.env.example @@ -388,6 +388,22 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # VITE_TKMIND_MODEL=gpt-4o # 可选:harness recall 的默认查询 + +# Harness 控制面(只读 shadow 投影 / 失败分类 / 对账;默认全部关闭) +# MEMIND_HARNESS_SCHEMA_REQUIRED=0 +# MEMIND_HARNESS_PROJECTOR_ENABLED=0 +# MEMIND_HARNESS_RECONCILER_ENABLED=0 +# MEMIND_HARNESS_ADMIN_API_ENABLED=0 +# MEMIND_HARNESS_SOURCE_EVENTS_ENABLED=0 +# MEMIND_HARNESS_CASES_ENABLED=0 +# MEMIND_HARNESS_REPLAY_ENABLED=0 +# MEMIND_HARNESS_REPLAY_ENV_GATE=0 +# HARNESS_LATENESS_BUDGET_MS=900000 +# HARNESS_PROJECTOR_BATCH_SIZE=200 +# HARNESS_PROJECTOR_LEASE_MS=60000 +# HARNESS_MIGRATION_MAX_MS=120000 +# 本地 shadow 验收:node scripts/verify-harness-shadow.mjs [--project-once] [--reconcile-once] + # Memory V2 Phase A canary (local dev) # Full template: docs/memory-v2/phase-a-canary.env.example # Readiness check: npm run check:memory-v2-phase-a @@ -535,6 +551,7 @@ MEMIND_RUNTIME_PROFILE=local # MEMIND_CURSOR_HELP_AUTO_GOOSE_REMEDIATE=1 # Track C: TKMind 智趣(Cursor)作为 code executor — 仅对白名单用户生效(memindadm「智趣体验通道」) +# 能力开关(pageGenerate/pageData/excelAnalysis/chatBridge/scheduledTasks)在管理后台配置,存 h5_wechat_cursor_executor_config # MEMIND_CURSOR_EXECUTOR_ENABLED=1 # 服务端 Cursor CLI 基础设施开关 # MEMIND_AIDER_SKILL_USE_CURSOR=1 # 已废弃全员路由:页面/问卷/Excel 是否走 Cursor 由 memindadm 白名单控制,不再靠 MEMIND_CURSOR_PAGE_TASKS_DEFAULT diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 770cc20..4263982 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -265,9 +265,10 @@ export function createPostAgentRunsHandler({ let requiredReviewExecutor = selectedSkillRuntime.requiredReviewExecutor ?? null; let cursorChannelEligible = false; + let cursorPolicy = null; if (cursorExecutorPolicyService?.getEffectivePolicy) { try { - const cursorPolicy = await cursorExecutorPolicyService.getEffectivePolicy( + cursorPolicy = await cursorExecutorPolicyService.getEffectivePolicy( request.currentUser.id, request.currentUser, ); @@ -278,6 +279,7 @@ export function createPostAgentRunsHandler({ }); } catch { cursorChannelEligible = false; + cursorPolicy = null; } } @@ -288,6 +290,7 @@ export function createPostAgentRunsHandler({ forceDeepReasoning, env: process.env, channelEligible: cursorChannelEligible, + policy: cursorPolicy, }); userMessage = pageCursorRuntime.userMessage; rawToolMode = pageCursorRuntime.rawToolMode; diff --git a/cursor-channel-features.mjs b/cursor-channel-features.mjs new file mode 100644 index 0000000..ec4863f --- /dev/null +++ b/cursor-channel-features.mjs @@ -0,0 +1,97 @@ +export const CURSOR_CHANNEL_FEATURES = Object.freeze({ + PAGE_GENERATE: 'pageGenerate', + PAGE_DATA: 'pageData', + EXCEL_ANALYSIS: 'excelAnalysis', + CHAT_BRIDGE: 'chatBridge', + SCHEDULED_TASKS: 'scheduledTasks', +}); + +export const CURSOR_TASK_KIND = Object.freeze({ + PAGE_GENERATION: 'page_generation', + PAGE_DATA: 'page_data', + EXCEL_ANALYSIS: 'excel_analysis', +}); + +const TASK_KIND_TO_FEATURE = Object.freeze({ + [CURSOR_TASK_KIND.PAGE_GENERATION]: CURSOR_CHANNEL_FEATURES.PAGE_GENERATE, + [CURSOR_TASK_KIND.PAGE_DATA]: CURSOR_CHANNEL_FEATURES.PAGE_DATA, + [CURSOR_TASK_KIND.EXCEL_ANALYSIS]: CURSOR_CHANNEL_FEATURES.EXCEL_ANALYSIS, +}); + +export function defaultCursorChannelFeatures() { + return { + [CURSOR_CHANNEL_FEATURES.PAGE_GENERATE]: { enabled: true }, + [CURSOR_CHANNEL_FEATURES.PAGE_DATA]: { enabled: false }, + [CURSOR_CHANNEL_FEATURES.EXCEL_ANALYSIS]: { enabled: false }, + [CURSOR_CHANNEL_FEATURES.CHAT_BRIDGE]: { enabled: false }, + [CURSOR_CHANNEL_FEATURES.SCHEDULED_TASKS]: { enabled: false }, + }; +} + +function normalizeFeatureFlag(value, fallback = false) { + if (value == null) return fallback; + if (typeof value === 'boolean') return value; + if (typeof value === 'object' && value !== null && 'enabled' in value) { + return normalizeFeatureFlag(value.enabled, fallback); + } + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +export function normalizeCursorChannelFeatures(rawFeatures, { + intentAllowlist = ['page.generate'], +} = {}) { + const defaults = defaultCursorChannelFeatures(); + const source = rawFeatures && typeof rawFeatures === 'object' ? rawFeatures : {}; + const legacyIntents = Array.isArray(intentAllowlist) + ? intentAllowlist.map((item) => String(item ?? '').trim()).filter(Boolean) + : []; + + const hasExplicitFeatures = Object.keys(source).length > 0; + return { + [CURSOR_CHANNEL_FEATURES.PAGE_GENERATE]: { + enabled: hasExplicitFeatures + ? normalizeFeatureFlag( + source[CURSOR_CHANNEL_FEATURES.PAGE_GENERATE], + legacyIntents.includes('page.generate'), + ) + : legacyIntents.includes('page.generate'), + }, + [CURSOR_CHANNEL_FEATURES.PAGE_DATA]: { + enabled: normalizeFeatureFlag(source[CURSOR_CHANNEL_FEATURES.PAGE_DATA], false), + }, + [CURSOR_CHANNEL_FEATURES.EXCEL_ANALYSIS]: { + enabled: normalizeFeatureFlag(source[CURSOR_CHANNEL_FEATURES.EXCEL_ANALYSIS], false), + }, + [CURSOR_CHANNEL_FEATURES.CHAT_BRIDGE]: { + enabled: normalizeFeatureFlag(source[CURSOR_CHANNEL_FEATURES.CHAT_BRIDGE], false), + }, + [CURSOR_CHANNEL_FEATURES.SCHEDULED_TASKS]: { + enabled: normalizeFeatureFlag(source[CURSOR_CHANNEL_FEATURES.SCHEDULED_TASKS], false), + }, + }; +} + +export function intentAllowlistFromFeatures(features) { + const normalized = normalizeCursorChannelFeatures(features); + const intents = []; + if (normalized[CURSOR_CHANNEL_FEATURES.PAGE_GENERATE]?.enabled) { + intents.push('page.generate'); + } + return intents.length > 0 ? intents : ['page.generate']; +} + +export function isCursorFeatureEnabled(policy, featureKey) { + if (!policy?.enabled) return false; + const features = policy.features + ?? normalizeCursorChannelFeatures(null, { intentAllowlist: policy.intentAllowlist }); + return normalizeFeatureFlag(features?.[featureKey], false); +} + +export function isCursorTaskKindEnabled(policy, taskKind) { + const featureKey = TASK_KIND_TO_FEATURE[String(taskKind ?? '').trim()]; + if (!featureKey) return false; + return isCursorFeatureEnabled(policy, featureKey); +} diff --git a/cursor-channel-features.test.mjs b/cursor-channel-features.test.mjs new file mode 100644 index 0000000..13397b0 --- /dev/null +++ b/cursor-channel-features.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CURSOR_CHANNEL_FEATURES, + CURSOR_TASK_KIND, + defaultCursorChannelFeatures, + intentAllowlistFromFeatures, + isCursorFeatureEnabled, + isCursorTaskKindEnabled, + normalizeCursorChannelFeatures, +} from './cursor-channel-features.mjs'; + +test('normalizeCursorChannelFeatures derives page.generate from legacy intent allowlist', () => { + const features = normalizeCursorChannelFeatures(null, { + intentAllowlist: ['page.generate'], + }); + assert.equal(features.pageGenerate.enabled, true); + assert.equal(features.pageData.enabled, false); +}); + +test('isCursorTaskKindEnabled respects per-feature toggles', () => { + const policy = { + enabled: true, + features: { + ...defaultCursorChannelFeatures(), + pageGenerate: { enabled: true }, + pageData: { enabled: false }, + }, + }; + assert.equal(isCursorTaskKindEnabled(policy, CURSOR_TASK_KIND.PAGE_GENERATION), true); + assert.equal(isCursorTaskKindEnabled(policy, CURSOR_TASK_KIND.PAGE_DATA), false); +}); + +test('intentAllowlistFromFeatures keeps wechat compatibility', () => { + const intents = intentAllowlistFromFeatures({ + [CURSOR_CHANNEL_FEATURES.PAGE_GENERATE]: { enabled: true }, + [CURSOR_CHANNEL_FEATURES.PAGE_DATA]: { enabled: true }, + }); + assert.deepEqual(intents, ['page.generate']); +}); + +test('isCursorFeatureEnabled requires master enabled flag', () => { + const policy = { + enabled: false, + features: defaultCursorChannelFeatures(), + }; + policy.features.pageGenerate.enabled = true; + assert.equal(isCursorFeatureEnabled(policy, CURSOR_CHANNEL_FEATURES.PAGE_GENERATE), false); +}); diff --git a/cursor-page-routing.mjs b/cursor-page-routing.mjs index f64088b..862da53 100644 --- a/cursor-page-routing.mjs +++ b/cursor-page-routing.mjs @@ -15,6 +15,7 @@ import { cursorPageTasksDefaultEnabled, resolvePreferredCodeExecutor, } from './cursor-agent-launch.mjs'; +import { isCursorTaskKindEnabled } from './cursor-channel-features.mjs'; const PUBLISH_SKILL_NAME = 'static-page-publish'; @@ -63,6 +64,7 @@ export function isPageCursorDefaultCandidate(userMessage, { rawToolMode = 'chat', env = process.env, channelEligible = false, + policy = null, } = {}) { if (!cursorExecutorEnabled(env)) return false; if (!channelEligible) return false; @@ -77,7 +79,12 @@ export function isPageCursorDefaultCandidate(userMessage, { const taskText = extractAiderDevelopmentTask(userMessage); if (!taskText) return false; - return resolveCursorTaskKind(taskText, skill) !== null; + const taskKind = resolveCursorTaskKind(taskText, skill); + if (taskKind === null) return false; + if (policy?.enabled) { + return isCursorTaskKindEnabled(policy, taskKind); + } + return true; } function buildCursorPageInstruction(taskText) { @@ -182,8 +189,9 @@ export function enforcePageGenerationCursorRuntime(userMessage, { forceDeepReasoning = false, env = process.env, channelEligible = false, + policy = null, } = {}) { - if (!isPageCursorDefaultCandidate(userMessage, { rawToolMode, env, channelEligible })) { + if (!isPageCursorDefaultCandidate(userMessage, { rawToolMode, env, channelEligible, policy })) { return { userMessage, rawToolMode, diff --git a/cursor-page-routing.test.mjs b/cursor-page-routing.test.mjs index 301148d..37ec87b 100644 --- a/cursor-page-routing.test.mjs +++ b/cursor-page-routing.test.mjs @@ -128,6 +128,30 @@ test('page cursor default is disabled without env flag', () => { assert.equal(isPageCursorDefaultCandidate(userMessage, { env: {} }), false); }); +test('page cursor default respects policy feature toggles', () => { + const userMessage = { + role: 'user', + content: [{ type: 'text', text: '帮我做一个调查问卷页面' }], + metadata: { displayText: '帮我做一个调查问卷页面' }, + }; + const policy = { + enabled: true, + features: { + pageGenerate: { enabled: false }, + pageData: { enabled: false }, + excelAnalysis: { enabled: false }, + }, + }; + assert.equal( + isPageCursorDefaultCandidate(userMessage, { + env: enabledEnv, + channelEligible: true, + policy, + }), + false, + ); +}); + test('applyCursorFirstAgentExecution no longer forces cursor for generic agent tasks', () => { const enabledEnv = { MEMIND_CURSOR_EXECUTOR_ENABLED: '1', diff --git a/llm-providers.mjs b/llm-providers.mjs index c72b589..b4b2300 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -2258,6 +2258,46 @@ export function createLlmProviderService( return { ok: true, providerId: goosedProviderId, model: row.default_model, source: 'vision' }; }, + async applyCursorChatBridgeForSession(sessionId, fetchImpl = apiFetchImpl) { + if (!cursorChatBridgeEnabled()) { + return { ok: false, message: 'TKMind Chat Bridge 未启用(MEMIND_CURSOR_CHAT_BRIDGE_ENABLED=1)' }; + } + const catalogItem = catalogById.custom_cursor; + const profile = { + providerId: 'custom_cursor', + defaultModel: catalogItem?.defaultModel ?? 'cursor-chat-bridge', + models: catalogItem?.models ?? ['cursor-chat-bridge'], + apiKey: 'cursor-bridge-local', + }; + const sessionGoosedApi = (pathname, init) => + goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl); + try { + const goosedProviderId = await syncCursorChatBridgeProfileToGoosed( + apiTarget, + apiSecret, + profile, + fetchImpl, + ); + await updateSessionProvider( + sessionGoosedApi, + sessionId, + goosedProviderId, + profile.defaultModel, + ); + return { + ok: true, + providerId: goosedProviderId, + model: profile.defaultModel, + source: 'cursor_chat_bridge', + }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : 'Cursor Chat Bridge 同步失败', + }; + } + }, + // Calls the vision provider directly (not through Goose) to analyze images. // Returns the model's text description, or null on failure. async analyzeImagesWithVision(imageItems, userText) { diff --git a/scheduled-task-executor.mjs b/scheduled-task-executor.mjs index ddc6e77..4c5893b 100644 --- a/scheduled-task-executor.mjs +++ b/scheduled-task-executor.mjs @@ -14,6 +14,8 @@ import { materializeMissingPublicHtmlWrites, } from './mindspace-public-finish-sync.mjs'; import { localDateLabel } from './schedule-time.mjs'; +import { executeCursorChannelCodeRun } from './wechat-cursor-agent-run.mjs'; +import { resolveCursorScheduledTaskEligible } from './wechat-cursor-executor-policy.mjs'; function messageText(message) { if (typeof message?.content === 'string') return message.content.trim(); @@ -529,6 +531,8 @@ export async function resendScheduledTaskWechatForReadyPage({ export async function executeScheduledTask(task, { userAuth, tkmindProxy, + agentRunGateway = null, + cursorExecutorPolicyService = null, sessionSnapshotService = null, pool = null, h5Root = null, @@ -539,13 +543,6 @@ export async function executeScheduledTask(task, { if (!userAuth || typeof userAuth.canUseChat !== 'function') { throw new Error('缺少 userAuth.canUseChat'); } - if ( - !tkmindProxy - || typeof tkmindProxy.startSessionForUser !== 'function' - || typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser !== 'function' - ) { - throw new Error('缺少 tkmindProxy 会话执行能力'); - } const gate = await userAuth.canUseChat(task.userId); if (!gate?.ok) { @@ -555,38 +552,97 @@ export async function executeScheduledTask(task, { } const requestId = crypto.randomUUID(); - const started = await tkmindProxy.startSessionForUser(task.userId, { - origin: 'h5', - }); - const sessionId = started?.id ?? started?.sessionId; - if (!sessionId) throw new Error('创建定时任务会话失败'); - const userMessage = buildScheduledTaskExecutionPrompt(task); - logger.info?.('[ScheduledTask] executing', { - taskId: task.id, - userId: task.userId, - sessionId, - requestId, - }); - - await tkmindProxy.submitSessionReplyAndAwaitFinishForUser( - task.userId, - sessionId, - requestId, - userMessage, - { timeoutMs }, - ); - - let messages = await refreshScheduledTaskMessages({ - userId: task.userId, - sessionId, - tkmindProxy, - sessionSnapshotService, - }); - const publishDir = h5Root && task.userId ? path.join(h5Root, 'MindSpace', task.userId) : null; + + let cursorPolicy = null; + let useCursorPath = false; + if (agentRunGateway && cursorExecutorPolicyService?.getEffectivePolicy) { + try { + cursorPolicy = await cursorExecutorPolicyService.getEffectivePolicy(task.userId, { + userId: task.userId, + }); + useCursorPath = resolveCursorScheduledTaskEligible({ + user: { userId: task.userId }, + policy: cursorPolicy, + }); + } catch (err) { + logger.warn?.('[ScheduledTask] cursor policy lookup failed:', err); + } + } + + let sessionId = null; + let messages = []; + + if (useCursorPath) { + logger.info?.('[ScheduledTask] executing via cursor channel', { + taskId: task.id, + userId: task.userId, + requestId, + }); + try { + const cursorResult = await executeCursorChannelCodeRun({ + agentRunGateway, + userId: task.userId, + requestId, + displayText: `定时任务:${task.title}`, + agentPrompt: userMessage.content[0]?.text ?? userMessage.content, + intentKind: 'page.generate', + channel: 'scheduled_task', + taskType: 'h5_chat_code_task', + policy: cursorPolicy, + forceCursorExecutor: true, + timeoutMs, + logger, + }); + messages = cursorResult.messages ?? []; + sessionId = cursorResult.runId ?? null; + } catch (cursorErr) { + if (cursorPolicy?.fallbackToDeepseek === false) throw cursorErr; + logger.warn?.('[ScheduledTask] cursor execution failed, falling back to goose:', cursorErr); + useCursorPath = false; + } + } + + if (!useCursorPath) { + if ( + !tkmindProxy + || typeof tkmindProxy.startSessionForUser !== 'function' + || typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser !== 'function' + ) { + throw new Error('缺少 tkmindProxy 会话执行能力'); + } + const started = await tkmindProxy.startSessionForUser(task.userId, { + origin: 'h5', + }); + sessionId = started?.id ?? started?.sessionId; + if (!sessionId) throw new Error('创建定时任务会话失败'); + + logger.info?.('[ScheduledTask] executing via goose session', { + taskId: task.id, + userId: task.userId, + sessionId, + requestId, + }); + + await tkmindProxy.submitSessionReplyAndAwaitFinishForUser( + task.userId, + sessionId, + requestId, + userMessage, + { timeoutMs }, + ); + + messages = await refreshScheduledTaskMessages({ + userId: task.userId, + sessionId, + tkmindProxy, + sessionSnapshotService, + }); + } + let deliveryText = extractScheduledTaskDeliveryText(messages, task); const deliveryResult = publishDir ? await awaitScheduledTaskPageDelivery({ @@ -624,5 +680,6 @@ export async function executeScheduledTask(task, { deliveryText, messages, readyPaths, + executor: useCursorPath ? 'cursor' : 'goose', }; } diff --git a/scheduled-task-worker.mjs b/scheduled-task-worker.mjs index c217c86..72e0417 100644 --- a/scheduled-task-worker.mjs +++ b/scheduled-task-worker.mjs @@ -14,6 +14,8 @@ export function startScheduledTaskWorker({ executeTask = executeScheduledTask, userAuth = null, tkmindProxy = null, + agentRunGateway = null, + cursorExecutorPolicyService = null, sessionSnapshotService = null, notificationDispatcher = null, pool = null, @@ -124,6 +126,8 @@ export function startScheduledTaskWorker({ const result = await executeTask(task, { userAuth, tkmindProxy, + agentRunGateway, + cursorExecutorPolicyService, sessionSnapshotService, pool, h5Root, diff --git a/scripts/apply-tang-cursor-config-103.mjs b/scripts/apply-tang-cursor-config-103.mjs new file mode 100644 index 0000000..7d620ac --- /dev/null +++ b/scripts/apply-tang-cursor-config-103.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * 将唐用户智趣体验通道更新为推荐配置(103 生产库) + * 默认 dry-run;--apply 才写入 h5_wechat_cursor_executor_config + * + * Usage: + * node scripts/apply-tang-cursor-config-103.mjs + * node scripts/apply-tang-cursor-config-103.mjs --apply + */ +import process from 'node:process'; +import { execSync } from 'node:child_process'; +import mysql from 'mysql2/promise'; +import { + CURSOR_CHANNEL_FEATURES, + defaultCursorChannelFeatures, + intentAllowlistFromFeatures, +} from '../cursor-channel-features.mjs'; + +const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db'; + +const RECOMMENDED = { + enabled: true, + userAllowlist: [TANG], + channelAllowlist: ['h5', 'wechat_mp'], + features: { + ...defaultCursorChannelFeatures(), + [CURSOR_CHANNEL_FEATURES.PAGE_GENERATE]: { enabled: true }, + [CURSOR_CHANNEL_FEATURES.PAGE_DATA]: { enabled: true }, + [CURSOR_CHANNEL_FEATURES.EXCEL_ANALYSIS]: { enabled: false }, + [CURSOR_CHANNEL_FEATURES.CHAT_BRIDGE]: { enabled: false }, + [CURSOR_CHANNEL_FEATURES.SCHEDULED_TASKS]: { enabled: true }, + }, + fallbackToDeepseek: true, + meta: { notes: '唐用户 Cursor 灰度 — 页面+问卷+定时任务' }, +}; + +RECOMMENDED.intentAllowlist = intentAllowlistFromFeatures(RECOMMENDED.features); + +async function main() { + const apply = process.argv.includes('--apply'); + const databaseUrl = execSync( + "ssh -o BatchMode=yes -o ConnectTimeout=8 john@58.38.22.103 \"grep '^DATABASE_URL=' /Users/john/Project/Memind/.env | cut -d= -f2-\"", + { encoding: 'utf8' }, + ).trim(); + const pool = mysql.createPool({ uri: databaseUrl, connectionLimit: 2 }); + const now = Date.now(); + + const [before] = await pool.query( + 'SELECT config_json FROM h5_wechat_cursor_executor_config WHERE config_scope = ? LIMIT 1', + ['global'], + ); + console.log('--- before ---'); + console.log(JSON.stringify(before[0]?.config_json ?? null, null, 2)); + console.log('--- target ---'); + console.log(JSON.stringify(RECOMMENDED, null, 2)); + + if (!apply) { + console.log('\n(dry-run) 追加 --apply 写入生产库'); + await pool.end(); + return; + } + + await pool.query( + `INSERT INTO h5_wechat_cursor_executor_config + (config_scope, config_json, updated_by, updated_at) + VALUES ('global', ?, NULL, ?) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_at = VALUES(updated_at)`, + [JSON.stringify(RECOMMENDED), now], + ); + + const [after] = await pool.query( + 'SELECT config_json, updated_at FROM h5_wechat_cursor_executor_config WHERE config_scope = ? LIMIT 1', + ['global'], + ); + console.log('\n--- applied ---'); + console.log(JSON.stringify(after[0] ?? null, null, 2)); + await pool.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/reactivate-tang-scheduled-tasks-103.mjs b/scripts/reactivate-tang-scheduled-tasks-103.mjs index d4d1ac0..a09f968 100644 --- a/scripts/reactivate-tang-scheduled-tasks-103.mjs +++ b/scripts/reactivate-tang-scheduled-tasks-103.mjs @@ -12,8 +12,8 @@ loadH5Environment(import.meta.dirname); const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db'; const DAILY_TASK_IDS = [ - '21799936-0532-420c-b144-65ea3846cde1', // 5:30 news - '23456db0-918e-4055-9ca3-e17e83b2dc24', // 8:00 weather + '816dc9a2-5304-4e45-bb29-667143997362', // 5:30 每日新闻早报页面 + '23456db0-918e-4055-9ca3-e17e83b2dc24', // 8:00 每日天气预报(上海+武穴) ]; const fmt = (ms) => new Date(Number(ms)).toLocaleString('zh-CN', { diff --git a/scripts/verify-tang-cursor-channel-103.mjs b/scripts/verify-tang-cursor-channel-103.mjs new file mode 100644 index 0000000..329da5b --- /dev/null +++ b/scripts/verify-tang-cursor-channel-103.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * 唐用户智趣通道 103 只读巡检 + 推荐配置输出 + * + * Usage: + * node scripts/verify-tang-cursor-channel-103.mjs + * node scripts/verify-tang-cursor-channel-103.mjs --print-recommended-patch + */ +import process from 'node:process'; +import { execSync } from 'node:child_process'; +import mysql from 'mysql2/promise'; +import { + CURSOR_CHANNEL_FEATURES, + defaultCursorChannelFeatures, + normalizeCursorChannelFeatures, +} from '../cursor-channel-features.mjs'; + +const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db'; +const TANG_USERNAME = 'wx_ul610et8'; + +const RECOMMENDED_FEATURES = { + ...defaultCursorChannelFeatures(), + [CURSOR_CHANNEL_FEATURES.PAGE_GENERATE]: { enabled: true }, + [CURSOR_CHANNEL_FEATURES.PAGE_DATA]: { enabled: true }, + [CURSOR_CHANNEL_FEATURES.EXCEL_ANALYSIS]: { enabled: false }, + [CURSOR_CHANNEL_FEATURES.CHAT_BRIDGE]: { enabled: false }, + [CURSOR_CHANNEL_FEATURES.SCHEDULED_TASKS]: { enabled: true }, +}; + +const args = process.argv.slice(2); +const printPatch = args.includes('--print-recommended-patch'); + +function pass(label, detail = '') { + console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`); +} + +function warn(label, detail = '') { + console.warn(`⚠ ${label}${detail ? `: ${detail}` : ''}`); +} + +function fail(label, detail = '') { + console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`); +} + +async function loadProdDatabaseUrl() { + return execSync( + "ssh -o BatchMode=yes -o ConnectTimeout=8 john@58.38.22.103 \"grep '^DATABASE_URL=' /Users/john/Project/Memind/.env | cut -d= -f2-\"", + { encoding: 'utf8' }, + ).trim(); +} + +async function read103Env(pattern) { + try { + const out = execSync( + `ssh -o BatchMode=yes -o ConnectTimeout=8 john@58.38.22.103 'grep -E "${pattern}" /Users/john/Project/Memind/.env || true'`, + { encoding: 'utf8' }, + ).trim(); + return out.split('\n').filter(Boolean); + } catch { + return []; + } +} + +async function main() { + let failed = 0; + const databaseUrl = await loadProdDatabaseUrl(); + const pool = mysql.createPool({ uri: databaseUrl, connectionLimit: 2 }); + + const [users] = await pool.query( + 'SELECT id, username, display_name FROM h5_users WHERE id = ? LIMIT 1', + [TANG], + ); + const user = users[0]; + if (user?.username === TANG_USERNAME) pass('唐用户身份', `${user.display_name} / ${user.username}`); + else { fail('唐用户身份'); failed += 1; } + + const envLines = await read103Env('^(MEMIND_CURSOR|MEMIND_CURSOR_CHAT_BRIDGE|H5_SCHEDULED_TASK)'); + const envMap = Object.fromEntries( + envLines.map((line) => { + const idx = line.indexOf('='); + return [line.slice(0, idx), line.slice(idx + 1)]; + }), + ); + + if (envMap.MEMIND_CURSOR_EXECUTOR_ENABLED === '1') pass('Cursor 基础设施', 'MEMIND_CURSOR_EXECUTOR_ENABLED=1'); + else { fail('Cursor 基础设施', '未开启 MEMIND_CURSOR_EXECUTOR_ENABLED'); failed += 1; } + + if (envMap.MEMIND_CURSOR_CHAT_BRIDGE_ENABLED === '1') { + pass('Chat Bridge 基础设施', '已配置'); + } else { + warn('Chat Bridge 基础设施', '未开 MEMIND_CURSOR_CHAT_BRIDGE_ENABLED;后台 chatBridge 开关暂不会生效'); + } + + if (envMap.H5_SCHEDULED_TASK_WORKER_ENABLED === '1') pass('定时任务 Worker', '已开启'); + else { warn('定时任务 Worker', 'H5_SCHEDULED_TASK_WORKER_ENABLED 未开'); } + + const [cursorRows] = await pool.query( + 'SELECT config_json FROM h5_wechat_cursor_executor_config WHERE config_scope = ? LIMIT 1', + ['global'], + ); + const stored = cursorRows[0]?.config_json ?? null; + const parsed = typeof stored === 'string' ? JSON.parse(stored) : (stored ?? {}); + const features = normalizeCursorChannelFeatures(parsed.features, { + intentAllowlist: parsed.intentAllowlist, + }); + + if (parsed.enabled) pass('智趣总开关', 'enabled'); + else { warn('智趣总开关', '当前关闭'); } + + const allowlist = Array.isArray(parsed.userAllowlist) ? parsed.userAllowlist : []; + if (allowlist.map((v) => v.toLowerCase()).includes(TANG.toLowerCase())) { + pass('唐在白名单', `${allowlist.length} 人`); + } else { + fail('唐在白名单', `当前: ${allowlist.join(', ') || '(空)'}`); + failed += 1; + } + + for (const [key, label] of [ + [CURSOR_CHANNEL_FEATURES.PAGE_GENERATE, '页面生成'], + [CURSOR_CHANNEL_FEATURES.PAGE_DATA, '问卷 Page Data'], + [CURSOR_CHANNEL_FEATURES.SCHEDULED_TASKS, '定时任务 Cursor'], + [CURSOR_CHANNEL_FEATURES.CHAT_BRIDGE, '普通聊天 Bridge'], + ]) { + const on = features[key]?.enabled === true; + const want = RECOMMENDED_FEATURES[key]?.enabled === true; + if (on === want) pass(`能力 ${label}`, on ? '已开' : '已关(符合推荐)'); + else warn(`能力 ${label}`, `当前=${on ? '开' : '关'},推荐=${want ? '开' : '关'}`); + } + + const [activeTasks] = await pool.query( + `SELECT COUNT(*) AS c FROM h5_scheduled_tasks WHERE user_id = ? AND status IN ('active','locked')`, + [TANG], + ); + const activeCount = Number(activeTasks[0]?.c ?? 0); + if (activeCount > 0) pass('active 定时任务', String(activeCount)); + else warn('active 定时任务', '0 — 需重建新闻/天气任务后才能验证 scheduledTasks'); + + const [failedTasks] = await pool.query( + `SELECT title, last_error FROM h5_scheduled_tasks + WHERE user_id = ? AND status = 'failed' + ORDER BY updated_at DESC LIMIT 5`, + [TANG], + ); + if (failedTasks.length) { + console.log('\n--- 最近 failed 定时任务 ---'); + for (const row of failedTasks) { + console.log(`- ${row.title}: ${String(row.last_error ?? '').slice(0, 120)}`); + } + } + + const recommendedPatch = { + enabled: true, + userAllowlist: [TANG], + channelAllowlist: ['h5', 'wechat_mp'], + features: RECOMMENDED_FEATURES, + fallbackToDeepseek: true, + }; + + if (printPatch) { + console.log('\n--- 推荐 memind_adm 保存 payload ---'); + console.log(JSON.stringify(recommendedPatch, null, 2)); + } else { + console.log('\n提示: node scripts/verify-tang-cursor-channel-103.mjs --print-recommended-patch'); + } + + await pool.end(); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs index f412aa7..8fcfc63 100644 --- a/server.mjs +++ b/server.mjs @@ -63,6 +63,7 @@ import { resolveRequestOrigin, } from './server/portal-publication-shell.mjs'; import { attachPortalRuntimeRoutes } from './server/portal-runtime-routes.mjs'; +import { createHarnessRuntimeService } from './services/harness/runtime.mjs'; import { attachPortalStaticDeliveryRoutes } from './server/portal-static-delivery-routes.mjs'; import { createPortalWorkspacePublicationDelivery } from './server/portal-workspace-publication-delivery.mjs'; import { attachPortalSeoDiscoveryRoutes } from './server/portal-seo-discovery-routes.mjs'; @@ -283,6 +284,7 @@ let memoryV2ConfigService = null; let skillRuntimeConfigService = null; let systemDisclosurePolicyService = null; let agentCodeRunPolicyService = null; +let harnessRuntimeService = null; let wechatCursorExecutorPolicyService = null; let wechatScheduleLlmConfigService = null; let wechatScheduledTaskManageLlmConfigService = null; @@ -338,6 +340,7 @@ async function bootstrapUserAuth() { try { if (!isDatabaseConfigured()) return false; const pool = createDbPool(); + harnessRuntimeService = createHarnessRuntimeService({ pool, env: process.env }); const domainServices = await bootstrapPortalDomainServices({ pool, @@ -517,6 +520,7 @@ async function bootstrapUserAuth() { conversationMemoryService, memoryV2, systemDisclosurePolicyService, + wechatCursorExecutorPolicyService, mindSpaceAssets, getWorkspacePublicationDelivery: () => mindSpaceWorkspacePublicationDelivery, @@ -789,6 +793,7 @@ attachPortalRuntimeRoutes(api, { getEpisodicMemoryService: () => episodicMemoryService, getAgentRunGateway: () => agentRunGateway, getCodeRunPolicyService: () => agentCodeRunPolicyService, + getHarnessRuntimeService: () => harnessRuntimeService, env: process.env, }); diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs index 9c52d5d..a908e72 100644 --- a/server/portal-gateway-services-bootstrap.mjs +++ b/server/portal-gateway-services-bootstrap.mjs @@ -134,6 +134,7 @@ export function bootstrapPortalGatewayServices({ conversationMemoryService, memoryV2, systemDisclosurePolicyService, + wechatCursorExecutorPolicyService = null, mindSpaceAssets, getWorkspacePublicationDelivery = () => null, @@ -182,6 +183,7 @@ export function bootstrapPortalGatewayServices({ conversationMemoryService, memoryV2, systemDisclosurePolicyService, + cursorExecutorPolicyService: wechatCursorExecutorPolicyService, localFetchAsset: mindSpaceAssets ? async (userId, assetId) => { const { asset, bodyBase64 } = diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 1fbd863..986689f 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -226,8 +226,8 @@ export async function bootstrapPortalIntegrationServices({ }); } }, - applySessionLlmProvider: (sessionId) => - tkmindProxy.applySessionLlmProvider(sessionId), + applySessionLlmProvider: (sessionId, options = {}) => + tkmindProxy.applySessionLlmProvider(sessionId, options), refreshSessionSnapshot: sessionSnapshotService?.isEnabled() ? (sessionId, userId) => @@ -300,6 +300,8 @@ export async function bootstrapPortalIntegrationServices({ scheduleService, userAuth, tkmindProxy, + agentRunGateway, + cursorExecutorPolicyService: wechatCursorExecutorPolicyService, sessionSnapshotService, notificationDispatcher, pool, diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index d446627..8716bf4 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -58,6 +58,7 @@ import { shouldPersistSessionStreamEvent, shouldSkipUpstreamAfterSessionReplay, } from './session-stream.mjs'; +import { resolveCursorChatBridgeEligible } from './wechat-cursor-executor-policy.mjs'; const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false }, @@ -1172,6 +1173,7 @@ export function createTkmindProxy({ sessionAccess = null, sessionStreamStore = null, llmProviderService, + cursorExecutorPolicyService = null, localFetchAsset, subscriptionService, billingConfigService = null, @@ -1570,7 +1572,7 @@ export function createTkmindProxy({ : null, }, ); - await applySessionLlmProvider(session.id); + await applySessionLlmProvider(session.id, { userId }); return session; } @@ -1600,14 +1602,36 @@ export function createTkmindProxy({ } } - async function applySessionLlmProvider(sessionId) { + async function applySessionLlmProvider(sessionId, { userId = null } = {}) { if (!llmProviderService || !sessionId) return null; try { const target = await resolveTarget(sessionId); - return await llmProviderService.applyBestProviderForSession( - sessionId, - (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init), - ); + const fetchImpl = (url, init) => + apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init); + + if (userId && cursorExecutorPolicyService?.getEffectivePolicy) { + try { + const cursorPolicy = await cursorExecutorPolicyService.getEffectivePolicy(userId, { + userId, + }); + if ( + resolveCursorChatBridgeEligible({ + user: { userId }, + policy: cursorPolicy, + }) + && typeof llmProviderService.applyCursorChatBridgeForSession === 'function' + ) { + return await llmProviderService.applyCursorChatBridgeForSession(sessionId, fetchImpl); + } + } catch (policyErr) { + console.warn( + 'Cursor chat bridge policy lookup skipped:', + policyErr instanceof Error ? policyErr.message : policyErr, + ); + } + } + + return await llmProviderService.applyBestProviderForSession(sessionId, fetchImpl); } catch (err) { console.warn( 'LLM provider apply skipped:', @@ -2027,7 +2051,7 @@ export function createTkmindProxy({ forceDeepReasoning, disableImageReading: disableImageReading || visionHandlesThisTurn, }); - await applySessionLlmProvider(sessionId); + await applySessionLlmProvider(sessionId, { userId }); await repairSessionToolHistory(sessionId); const user = await userAuth.getUserById(userId); @@ -2406,7 +2430,7 @@ export function createTkmindProxy({ }); return; } - await applySessionLlmProvider(session.id); + await applySessionLlmProvider(session.id, { userId: req.currentUser.id }); } res.status(upstream.status).json(session); } catch (err) { @@ -2491,7 +2515,7 @@ export function createTkmindProxy({ } } - await applySessionLlmProvider(sessionId); + await applySessionLlmProvider(sessionId, { userId: req.currentUser.id }); res.status(upstream.status).json(payload); } catch (err) { diff --git a/wechat-cursor-agent-run.mjs b/wechat-cursor-agent-run.mjs index 4a9cd96..9592f5e 100644 --- a/wechat-cursor-agent-run.mjs +++ b/wechat-cursor-agent-run.mjs @@ -10,6 +10,7 @@ function sleep(ms) { function buildWechatCursorUserMessage({ displayText, intentKind, + channel = 'wechat_mp', }) { const taskText = String(displayText ?? '').trim(); return { @@ -18,9 +19,9 @@ function buildWechatCursorUserMessage({ metadata: { displayText: taskText, memindRun: { - channel: 'wechat_mp', - wechatCursorChannel: true, - taskType: 'wechat_page_generate', + channel, + wechatCursorChannel: channel === 'wechat_mp', + taskType: channel === 'wechat_mp' ? 'wechat_page_generate' : 'h5_chat_code_task', toolMode: 'code', requiredExecutor: 'cursor', intentKind: String(intentKind ?? '').trim() || null, @@ -42,6 +43,123 @@ async function readCursorCompletionText(agentRunGateway, userId, runId) { }); } +export async function executeCursorChannelCodeRun({ + agentRunGateway, + userId, + sessionId = null, + requestId, + displayText, + agentPrompt = null, + intentKind = 'page.generate', + channel = 'wechat_mp', + taskType = null, + policy = null, + forceCursorExecutor = false, + timeoutMs = 15 * 60 * 1000, + pollMs = DEFAULT_POLL_MS, + logger = console, +} = {}) { + if (!agentRunGateway?.createRun || !agentRunGateway?.dispatchRun || !agentRunGateway?.getRunForUser) { + throw Object.assign(new Error('Cursor 执行网关不可用'), { + code: 'CURSOR_CHANNEL_GATEWAY_UNAVAILABLE', + }); + } + + const promptText = String(agentPrompt ?? displayText ?? '').trim(); + let userMessage = buildWechatCursorUserMessage({ + displayText: String(displayText ?? promptText).trim(), + intentKind, + channel, + }); + if (promptText) { + userMessage = { + ...userMessage, + content: [{ type: 'text', text: promptText }], + }; + } + + const resolvedTaskType = taskType + ?? (channel === 'wechat_mp' ? 'wechat_page_generate' : 'h5_chat_code_task'); + let cursorRuntime = enforcePageGenerationCursorRuntime(userMessage, { + rawToolMode: 'code', + taskType: resolvedTaskType, + env: process.env, + channelEligible: true, + policy, + }); + if (forceCursorExecutor && !cursorRuntime.requiredExecutor) { + cursorRuntime = { + ...cursorRuntime, + rawToolMode: 'code', + taskType: resolvedTaskType, + requiredExecutor: 'cursor', + }; + userMessage = { + ...userMessage, + metadata: { + ...(userMessage.metadata ?? {}), + memindRun: { + ...(userMessage.metadata?.memindRun ?? {}), + requiredExecutor: 'cursor', + executor: 'cursor', + toolMode: 'code', + }, + }, + }; + } else { + userMessage = cursorRuntime.userMessage; + } + + const run = await agentRunGateway.createRun(userId, { + sessionId, + requestId, + userMessage, + toolMode: 'code', + taskType: cursorRuntime.taskType ?? resolvedTaskType, + }); + agentRunGateway.dispatchRun(run.id); + + const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); + while (Date.now() <= deadline) { + const latest = await agentRunGateway.getRunForUser(userId, run.id); + if (!latest) { + throw Object.assign(new Error('Cursor 任务丢失'), { + code: 'CURSOR_CHANNEL_RUN_MISSING', + }); + } + if (latest.status === 'succeeded') { + const text = await readCursorCompletionText(agentRunGateway, userId, run.id); + logger.info?.('[cursor-channel] run succeeded', { + userId, + runId: run.id, + requestId, + channel, + }); + return { + text, + runId: run.id, + tokenState: null, + messages: [{ + role: 'assistant', + content: [{ type: 'text', text }], + metadata: { userVisible: true, source: 'cursor-channel-agent-run', channel }, + }], + requestMessages: [], + }; + } + if (latest.status === 'failed') { + const error = new Error(latest.error || 'Cursor 执行失败'); + error.code = 'CURSOR_CHANNEL_RUN_FAILED'; + throw error; + } + await sleep(Math.max(500, Number(pollMs) || DEFAULT_POLL_MS)); + } + + throw Object.assign(new Error('Cursor 执行超时'), { + code: 'CURSOR_CHANNEL_RUN_TIMEOUT', + }); +} + export async function executeWechatCursorAgentRun({ agentRunGateway, userId, @@ -50,69 +168,24 @@ export async function executeWechatCursorAgentRun({ displayText, agentPrompt, intentKind = 'page.generate', + policy = null, timeoutMs = 15 * 60 * 1000, pollMs = DEFAULT_POLL_MS, logger = console, } = {}) { - if (!agentRunGateway?.createRun || !agentRunGateway?.dispatchRun || !agentRunGateway?.getRunForUser) { - throw Object.assign(new Error('WeChat Cursor 执行网关不可用'), { - code: 'WECHAT_CURSOR_GATEWAY_UNAVAILABLE', - }); - } - - let userMessage = buildWechatCursorUserMessage({ displayText, intentKind }); - const cursorRuntime = enforcePageGenerationCursorRuntime(userMessage, { - rawToolMode: 'code', - taskType: 'wechat_page_generate', - env: process.env, - channelEligible: true, - }); - userMessage = cursorRuntime.userMessage; - - const run = await agentRunGateway.createRun(userId, { + return executeCursorChannelCodeRun({ + agentRunGateway, + userId, sessionId, requestId, - userMessage, - toolMode: 'code', - taskType: cursorRuntime.taskType ?? 'h5_chat_code_task', - }); - agentRunGateway.dispatchRun(run.id); - - const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); - while (Date.now() <= deadline) { - const latest = await agentRunGateway.getRunForUser(userId, run.id); - if (!latest) { - throw Object.assign(new Error('WeChat Cursor 任务丢失'), { - code: 'WECHAT_CURSOR_RUN_MISSING', - }); - } - if (latest.status === 'succeeded') { - const text = await readCursorCompletionText(agentRunGateway, userId, run.id); - logger.info?.('[wechat-cursor] run succeeded', { - userId, - runId: run.id, - requestId, - }); - return { - text, - tokenState: null, - messages: [{ - role: 'assistant', - content: [{ type: 'text', text }], - metadata: { userVisible: true, source: 'wechat-cursor-agent-run' }, - }], - requestMessages: [], - }; - } - if (latest.status === 'failed') { - const error = new Error(latest.error || 'WeChat Cursor 执行失败'); - error.code = 'WECHAT_CURSOR_RUN_FAILED'; - throw error; - } - await sleep(Math.max(500, Number(pollMs) || DEFAULT_POLL_MS)); - } - - throw Object.assign(new Error('WeChat Cursor 执行超时'), { - code: 'WECHAT_CURSOR_RUN_TIMEOUT', + displayText, + agentPrompt, + intentKind, + channel: 'wechat_mp', + taskType: 'wechat_page_generate', + policy, + timeoutMs, + pollMs, + logger, }); } diff --git a/wechat-cursor-executor-admin-config.mjs b/wechat-cursor-executor-admin-config.mjs index 742586d..22f8f7a 100644 --- a/wechat-cursor-executor-admin-config.mjs +++ b/wechat-cursor-executor-admin-config.mjs @@ -1,3 +1,12 @@ +import { + CURSOR_CHANNEL_FEATURES, + defaultCursorChannelFeatures, + intentAllowlistFromFeatures, + isCursorFeatureEnabled, + isCursorTaskKindEnabled, + normalizeCursorChannelFeatures, +} from './cursor-channel-features.mjs'; + const CONFIG_TABLE = 'h5_wechat_cursor_executor_config'; const CONFIG_SCOPE = 'global'; const POLICY_SOURCE_DEFAULT = 'default'; @@ -6,17 +15,21 @@ const POLICY_SOURCE_ADMIN_DB = 'admin-db'; const DEFAULT_INTENT_ALLOWLIST = Object.freeze(['page.generate']); const DEFAULT_CHANNEL_ALLOWLIST = Object.freeze(['h5', 'wechat_mp']); +export { CURSOR_CHANNEL_FEATURES, isCursorFeatureEnabled, isCursorTaskKindEnabled }; + export const CURSOR_EXECUTOR_CHANNEL = Object.freeze({ H5: 'h5', WECHAT_MP: 'wechat_mp', }); function defaultConfigShape() { + const features = defaultCursorChannelFeatures(); return { enabled: false, userAllowlist: [], channelAllowlist: [...DEFAULT_CHANNEL_ALLOWLIST], - intentAllowlist: [...DEFAULT_INTENT_ALLOWLIST], + intentAllowlist: intentAllowlistFromFeatures(features), + features, fallbackToDeepseek: true, meta: { notes: '', @@ -61,6 +74,25 @@ function parseJsonLike(value, fallback) { return fallback; } +function mergeFeaturePatch(currentFeatures, patchFeatures) { + const base = normalizeCursorChannelFeatures(currentFeatures, { + intentAllowlist: DEFAULT_INTENT_ALLOWLIST, + }); + if (!patchFeatures || typeof patchFeatures !== 'object') return base; + const next = { ...base }; + for (const featureKey of Object.values(CURSOR_CHANNEL_FEATURES)) { + if (!(featureKey in patchFeatures)) continue; + const raw = patchFeatures[featureKey]; + next[featureKey] = { + enabled: normalizeBoolean( + typeof raw === 'object' && raw !== null ? raw.enabled : raw, + base[featureKey]?.enabled ?? false, + ), + }; + } + return next; +} + function mergePatch(currentConfig, patch = {}) { const next = cloneConfig(currentConfig); if ('enabled' in patch) next.enabled = normalizeBoolean(patch.enabled, false); @@ -69,9 +101,17 @@ function mergePatch(currentConfig, patch = {}) { const channels = normalizeStringList(patch.channelAllowlist); next.channelAllowlist = channels.length ? channels : [...DEFAULT_CHANNEL_ALLOWLIST]; } + if ('features' in patch) { + next.features = mergeFeaturePatch(next.features, patch.features); + } if ('intentAllowlist' in patch) { const intents = normalizeStringList(patch.intentAllowlist); next.intentAllowlist = intents.length ? intents : [...DEFAULT_INTENT_ALLOWLIST]; + next.features = normalizeCursorChannelFeatures(next.features, { + intentAllowlist: next.intentAllowlist, + }); + } else if ('features' in patch) { + next.intentAllowlist = intentAllowlistFromFeatures(next.features); } if ('fallbackToDeepseek' in patch) { next.fallbackToDeepseek = normalizeBoolean(patch.fallbackToDeepseek, true); @@ -79,18 +119,27 @@ function mergePatch(currentConfig, patch = {}) { if (patch?.meta && typeof patch.meta.notes === 'string') { next.meta.notes = patch.meta.notes; } + if (!next.features) { + next.features = normalizeCursorChannelFeatures(null, { + intentAllowlist: next.intentAllowlist, + }); + } return next; } function flattenPolicy(config, source) { const intentAllowlist = normalizeStringList(config.intentAllowlist); const channelAllowlist = normalizeStringList(config.channelAllowlist); + const features = normalizeCursorChannelFeatures(config.features, { + intentAllowlist: intentAllowlist.length ? intentAllowlist : [...DEFAULT_INTENT_ALLOWLIST], + }); return { source, enabled: Boolean(config.enabled), userAllowlist: normalizeStringList(config.userAllowlist), channelAllowlist: channelAllowlist.length ? channelAllowlist : [...DEFAULT_CHANNEL_ALLOWLIST], - intentAllowlist: intentAllowlist.length ? intentAllowlist : [...DEFAULT_INTENT_ALLOWLIST], + intentAllowlist: intentAllowlistFromFeatures(features), + features, fallbackToDeepseek: config.fallbackToDeepseek !== false, }; } @@ -123,11 +172,14 @@ export function isUserAllowedByWechatCursorPolicy(user, policy) { } export function isIntentAllowedByWechatCursorPolicy(intentKind, policy) { + const normalized = String(intentKind ?? '').trim(); + if (!normalized) return false; + if (normalized === 'page.generate') { + return isCursorFeatureEnabled(policy, CURSOR_CHANNEL_FEATURES.PAGE_GENERATE); + } const allowlist = (policy?.intentAllowlist ?? DEFAULT_INTENT_ALLOWLIST) .map((item) => String(item ?? '').trim()) .filter(Boolean); - const normalized = String(intentKind ?? '').trim(); - if (!normalized) return false; return allowlist.includes(normalized); } @@ -154,8 +206,9 @@ async function loadStoredState(pool) { const row = rows[0]; if (!row) return null; const parsed = parseJsonLike(row.config_json, {}); + const merged = mergePatch(defaultConfigShape(), parsed); return { - config: mergePatch(defaultConfigShape(), parsed), + config: merged, updatedAt: Number(row.updated_at ?? 0) || null, updatedBy: row.updated_by ?? null, }; diff --git a/wechat-cursor-executor-policy.mjs b/wechat-cursor-executor-policy.mjs index e37c99d..65bd2d4 100644 --- a/wechat-cursor-executor-policy.mjs +++ b/wechat-cursor-executor-policy.mjs @@ -1,11 +1,13 @@ import { CURSOR_EXECUTOR_CHANNEL, isChannelAllowedByCursorPolicy, + isCursorFeatureEnabled, isIntentAllowedByWechatCursorPolicy, isUserAllowedByWechatCursorPolicy, } from './wechat-cursor-executor-admin-config.mjs'; +import { CURSOR_CHANNEL_FEATURES } from './cursor-channel-features.mjs'; -export { CURSOR_EXECUTOR_CHANNEL }; +export { CURSOR_EXECUTOR_CHANNEL, CURSOR_CHANNEL_FEATURES }; export function resolveCursorChannelEligible({ user = null, @@ -25,6 +27,32 @@ export function resolveCursorChannelEligible({ return true; } +export function resolveCursorChatBridgeEligible({ + user = null, + userId = null, + policy = null, + env = process.env, +} = {}) { + if (!policy?.enabled) return false; + const subject = user ?? { userId }; + if (!isUserAllowedByWechatCursorPolicy(subject, policy)) return false; + if (!isCursorFeatureEnabled(policy, CURSOR_CHANNEL_FEATURES.CHAT_BRIDGE)) return false; + const bridgeEnvEnabled = String(env?.MEMIND_CURSOR_CHAT_BRIDGE_ENABLED ?? '').trim(); + if (!['1', 'true', 'yes', 'on'].includes(bridgeEnvEnabled.toLowerCase())) return false; + return true; +} + +export function resolveCursorScheduledTaskEligible({ + user = null, + userId = null, + policy = null, +} = {}) { + if (!policy?.enabled) return false; + const subject = user ?? { userId }; + if (!isUserAllowedByWechatCursorPolicy(subject, policy)) return false; + return isCursorFeatureEnabled(policy, CURSOR_CHANNEL_FEATURES.SCHEDULED_TASKS); +} + export function resolveWechatCursorExecutorEligible({ user = null, userId = null, diff --git a/wechat-cursor-executor-policy.test.mjs b/wechat-cursor-executor-policy.test.mjs index 18bb5ee..891f512 100644 --- a/wechat-cursor-executor-policy.test.mjs +++ b/wechat-cursor-executor-policy.test.mjs @@ -9,6 +9,7 @@ import { import { CURSOR_EXECUTOR_CHANNEL, resolveCursorChannelEligible, + resolveCursorScheduledTaskEligible, resolveWechatCursorExecutorEligible, } from './wechat-cursor-executor-policy.mjs'; @@ -49,11 +50,18 @@ test('allowlisted user can use cursor channel for page.generate only', async () await service.updateAdminConfig({ enabled: true, userAllowlist: ['john-uuid'], - intentAllowlist: ['page.generate'], + features: { + pageGenerate: { enabled: true }, + pageData: { enabled: false }, + excelAnalysis: { enabled: false }, + chatBridge: { enabled: false }, + scheduledTasks: { enabled: false }, + }, }, { updatedBy: 'admin-1' }); const policy = await service.getEffectivePolicy('john-uuid', { userId: 'john-uuid' }); assert.equal(policy.userAllowed, true); + assert.equal(policy.features.pageGenerate.enabled, true); assert.equal( resolveWechatCursorExecutorEligible({ user: { userId: 'john-uuid' }, @@ -80,6 +88,24 @@ test('allowlisted user can use cursor channel for page.generate only', async () ); }); +test('resolveCursorScheduledTaskEligible follows scheduledTasks feature toggle', async () => { + const pool = createMemoryPool(); + const service = createWechatCursorExecutorAdminConfigService(pool); + await service.updateAdminConfig({ + enabled: true, + userAllowlist: ['john-uuid'], + features: { + pageGenerate: { enabled: true }, + scheduledTasks: { enabled: true }, + }, + }, { updatedBy: 'admin-1' }); + const policy = await service.getEffectivePolicy('john-uuid', { userId: 'john-uuid' }); + assert.equal( + resolveCursorScheduledTaskEligible({ user: { userId: 'john-uuid' }, policy }), + true, + ); +}); + test('isUserAllowedByWechatCursorPolicy matches username aliases', () => { const policy = { enabled: true, userAllowlist: ['john'] }; assert.equal( @@ -90,7 +116,17 @@ test('isUserAllowedByWechatCursorPolicy matches username aliases', () => { }); test('isIntentAllowedByWechatCursorPolicy respects allowlist', () => { - const policy = { intentAllowlist: ['page.generate'] }; + const policy = { + enabled: true, + intentAllowlist: ['page.generate'], + features: { + pageGenerate: { enabled: true }, + pageData: { enabled: false }, + excelAnalysis: { enabled: false }, + chatBridge: { enabled: false }, + scheduledTasks: { enabled: false }, + }, + }; assert.equal(isIntentAllowedByWechatCursorPolicy('page.generate', policy), true); assert.equal(isIntentAllowedByWechatCursorPolicy('chat.general', policy), false); }); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 4b2d266..150763c 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -2179,9 +2179,9 @@ export function createWechatMpService({ throw notifyFailure ? markWechatUserNotified(error) : error; }; - const ensureSessionProvider = async (sessionId) => { + const ensureSessionProvider = async (sessionId, userId = null) => { if (!applySessionLlmProvider || !sessionId) return; - const applied = await applySessionLlmProvider(sessionId); + const applied = await applySessionLlmProvider(sessionId, { userId }); if (applied && applied.ok === false) { throw new Error(applied.message || '专属 Agent Provider 未配置'); } @@ -2539,6 +2539,9 @@ export function createWechatMpService({ void refreshWechatSessionSnapshot(sessionId, userId); }; + /** Cursor channel writes the assistant turn via respondDeterministically; goose stays empty. */ + const shouldRefreshWechatSessionSnapshot = (reply) => !isWechatCursorChannelReply(reply); + const rememberWechatUserContext = async (sessionId, user, { forceBootstrap = false } = {}) => { const addressName = resolveWechatAddressName(user); if (!addressName) return; @@ -2820,7 +2823,7 @@ export function createWechatMpService({ }); let sessionId = route.sessionId; let carriedSessionContent = String(route.carriedSessionContent ?? '').trim(); - await ensureSessionProvider(sessionId); + await ensureSessionProvider(sessionId, user.userId); if (wechatIntent.kind === 'session.reset') { await sendCustomerServiceText( inbound.fromUserName, @@ -2847,7 +2850,7 @@ export function createWechatMpService({ if (pollutionRotation.carriedSessionContent) { carriedSessionContent = pollutionRotation.carriedSessionContent; } - await ensureSessionProvider(sessionId); + await ensureSessionProvider(sessionId, user.userId); await rememberWechatUserContext(sessionId, user, { forceBootstrap: true }); } if ( @@ -2964,6 +2967,7 @@ export function createWechatMpService({ displayText: String(intent?.displayText ?? intent?.agentText ?? '').trim(), agentPrompt: activeAgentPrompt, intentKind: wechatIntent.kind, + policy: wechatCursorPolicy, timeoutMs: agentReplyTimeoutMs, logger, }); @@ -3279,7 +3283,9 @@ export function createWechatMpService({ finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]); } } - scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); + if (shouldRefreshWechatSessionSnapshot(finalizedReply)) { + scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); + } const delivery = await sendCustomerServiceText( inbound.fromUserName, await guardScheduleReply(finalizedReply), @@ -3386,7 +3392,7 @@ export function createWechatMpService({ userContext: user, }); sessionId = route.sessionId; - await ensureSessionProvider(sessionId); + await ensureSessionProvider(sessionId, user.userId); await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); if (historicalImageError) { prepareWechatIntentForHistoricalImageRetry(intent, { @@ -3668,7 +3674,9 @@ export function createWechatMpService({ finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]); } } - scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); + if (shouldRefreshWechatSessionSnapshot(finalizedReply)) { + scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); + } const retryDelivery = await sendCustomerServiceText( inbound.fromUserName, await guardScheduleReply(finalizedReply),