Add per-feature Cursor channel toggles for admin and runtime.
Memind CI / Test, build, and release guards (push) Has been cancelled

Expose page/data/scheduled-task/chat-bridge switches in the智趣体验通道 config so Tang can roll out Cursor paths independently with DeepSeek fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-31 09:40:52 +08:00
parent ac2c87be43
commit 617ff0d1dd
21 changed files with 915 additions and 127 deletions
+17
View File
@@ -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
+4 -1
View File
@@ -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;
+97
View File
@@ -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);
}
+49
View File
@@ -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);
});
+10 -2
View File
@@ -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,
+24
View File
@@ -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',
+40
View File
@@ -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) {
+92 -35
View File
@@ -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',
};
}
+4
View File
@@ -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,
+85
View File
@@ -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);
});
@@ -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', {
+173
View File
@@ -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);
});
+5
View File
@@ -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,
});
@@ -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 } =
@@ -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,
+33 -9
View File
@@ -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) {
+134 -61
View File
@@ -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,
});
}
+58 -5
View File
@@ -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,
};
+29 -1
View File
@@ -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,
+38 -2
View File
@@ -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);
});
+15 -7
View File
@@ -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),