feat: LLM intent router, direct chat execution, and Memory V2 light intervention

Wire chat intent routing with direct_chat on regular sessions, skill-selected
short-circuit to Agent, memory light/heavy intervention tiers, and fix direct
chat UI stuck streaming after completion.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-04 22:32:57 +08:00
parent bfb6356f7d
commit e45c9300bf
33 changed files with 3412 additions and 105 deletions
+132 -23
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { MEMORY_INTERVENTION_LIMIT } from './memory-intervention.mjs';
export const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_';
@@ -117,11 +118,22 @@ function renderMemoryLines(memories) {
].join('\n');
}
function buildModelMessages({ previousMessages, userMessage, memories }) {
function buildMemorySystemBlock({ memories, routingMemoryContent } = {}) {
const routed = String(routingMemoryContent ?? '').trim();
if (routed) {
return [
'以下是当前用户的长期记忆,只用于改善回答,不要主动暴露记忆来源:',
routed,
].join('\n');
}
return renderMemoryLines(memories);
}
function buildModelMessages({ previousMessages, userMessage, memories, routingMemoryContent }) {
const system = [
'你是 TKMind H5 聊天助手。',
'优先直接回答用户问题;不要调用工具;涉及需要执行代码、改文件、生成页面或操作外部系统的任务时,简要说明已交由后台任务处理或请用户确认具体任务。',
renderMemoryLines(memories),
buildMemorySystemBlock({ memories, routingMemoryContent }),
].filter(Boolean).join('\n\n');
const history = Array.isArray(previousMessages) ? previousMessages.slice(-12) : [];
@@ -146,10 +158,56 @@ function buildAssistantMessage(reply, { requestId, now }) {
metadata: {
userVisible: true,
source: 'portal-direct-chat',
chatRequestId: requestId || undefined,
},
};
}
function evaluateCanHandle({
sessionId = null,
toolMode = 'chat',
userMessage,
routingDecision = null,
enabled: enabledOverride,
userAuth,
llmProviderService,
sessionSnapshotService,
} = {}) {
const resolvedEnabled = enabledOverride ?? true;
if (!resolvedEnabled) return { ok: false, reason: 'disabled' };
if (toolMode !== 'chat') return { ok: false, reason: 'tool_mode_not_chat' };
if (routingDecision === 'agent_orchestration') {
return { ok: false, reason: 'routing_agent_orchestration' };
}
if (
sessionId &&
!isDirectChatSessionId(sessionId) &&
routingDecision !== 'direct_chat'
) {
return { ok: false, reason: 'session_not_direct_chat' };
}
if (!isTextOnlyUserMessage(userMessage)) {
return { ok: false, reason: 'non_text_message' };
}
if (routingDecision !== 'direct_chat' && requiresTaskExecution(userMessage)) {
return { ok: false, reason: 'task_execution_required' };
}
if (!userAuth || !llmProviderService || !sessionSnapshotService) {
return { ok: false, reason: 'dependencies_unavailable' };
}
return { ok: true, reason: null };
}
export function isPortalDirectChatSnapshot(snapshot) {
const messages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
const lastAssistant = [...messages].reverse().find((message) => message?.role === 'assistant');
if (lastAssistant?.metadata?.source !== 'portal-direct-chat') return false;
const updatedAtRaw = snapshot?.session?.updated_at ?? snapshot?.meta?.saved_at ?? 0;
const updatedAt = Date.parse(String(updatedAtRaw)) || Number(updatedAtRaw) || 0;
if (!updatedAt) return true;
return Date.now() - updatedAt <= 5 * 60 * 1000;
}
function normalizeUsageForBilling(usage, previousState) {
if (!usage) return null;
const input = Math.max(0, Number(usage.inputTokens ?? 0) || 0);
@@ -167,7 +225,7 @@ export function createDirectChatService({
sessionSnapshotService,
memoryV2 = null,
conversationMemoryService = null,
enabled = envFlag(process.env.MEMIND_DIRECT_CHAT_ENABLED, false),
enabled = envFlag(process.env.MEMIND_DIRECT_CHAT_ENABLED, true),
} = {}) {
function getStatus() {
return {
@@ -176,29 +234,48 @@ export function createDirectChatService({
};
}
function canHandle({ sessionId = null, toolMode = 'chat', userMessage } = {}) {
if (!enabled) return false;
if (toolMode !== 'chat') return false;
if (sessionId && !isDirectChatSessionId(sessionId)) return false;
if (!isTextOnlyUserMessage(userMessage)) return false;
if (requiresTaskExecution(userMessage)) return false;
return Boolean(userAuth && llmProviderService && sessionSnapshotService);
function canHandle(input = {}) {
return evaluateCanHandle({
...input,
enabled,
userAuth,
llmProviderService,
sessionSnapshotService,
}).ok;
}
async function resolveMemories(userId, sessionId, query) {
if (!userId) return [];
function explainCanHandle(input = {}) {
return evaluateCanHandle({
...input,
enabled,
userAuth,
llmProviderService,
sessionSnapshotService,
});
}
async function resolveMemories(userId, sessionId, query, { limit = MEMORY_INTERVENTION_LIMIT.LIGHT_DIRECT_CHAT } = {}) {
if (!userId || limit <= 0) return [];
if (memoryV2?.resolve) {
const result = await memoryV2.resolve({ userId, sessionId, query, limit: 40 }).catch(() => null);
const result = await memoryV2.resolve({ userId, sessionId, query, limit }).catch(() => null);
return Array.isArray(result?.memories) ? result.memories : [];
}
if (conversationMemoryService?.listMemories) {
return conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []);
return conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
}
return [];
}
async function run({ userId, sessionId = null, requestId, userMessage } = {}) {
if (!canHandle({ sessionId, toolMode: 'chat', userMessage })) {
async function run({
userId,
sessionId = null,
requestId,
userMessage,
routingDecision = null,
routingMemory = null,
onSessionReady = null,
} = {}) {
if (!canHandle({ sessionId, toolMode: 'chat', userMessage, routingDecision })) {
const err = new Error('Direct chat is not available for this run');
err.code = 'DIRECT_CHAT_UNAVAILABLE';
err.retryable = false;
@@ -208,9 +285,44 @@ export function createDirectChatService({
const activeSessionId = sessionId || createDirectSessionId();
const snapshot = await sessionSnapshotService.get(activeSessionId).catch(() => null);
const previousMessages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
const memories = await resolveMemories(userId, activeSessionId, messageText(userMessage));
const now = new Date().toISOString();
if (!sessionId) {
await userAuth.registerAgentSession(userId, activeSessionId, 'h5-direct');
}
if (typeof onSessionReady === 'function') {
await onSessionReady(activeSessionId);
}
const pendingMessages = [...previousMessages, userMessage];
await sessionSnapshotService.save(
activeSessionId,
userId,
{
id: activeSessionId,
name: snapshot?.session?.name ?? 'New Chat',
working_dir: snapshot?.session?.working_dir ?? '',
message_count: pendingMessages.length,
created_at: snapshot?.session?.created_at ?? now,
updated_at: now,
user_set_name: snapshot?.session?.user_set_name ?? false,
recipe: snapshot?.session?.recipe ?? null,
conversation: pendingMessages,
},
pendingMessages,
);
const routedMemoryContent =
routingMemory && !routingMemory.skipped && !routingMemory.degraded
? String(routingMemory.content ?? '').trim()
: '';
const memories = routedMemoryContent
? []
: await resolveMemories(userId, activeSessionId, messageText(userMessage));
const completion = await llmProviderService.createChatCompletion({
messages: buildModelMessages({ previousMessages, userMessage, memories }),
messages: buildModelMessages({
previousMessages,
userMessage,
memories,
routingMemoryContent: routedMemoryContent,
}),
});
if (!completion?.ok) {
const err = new Error(completion?.message ?? 'Portal 直连聊天失败');
@@ -218,9 +330,8 @@ export function createDirectChatService({
throw err;
}
const now = new Date().toISOString();
const assistantMessage = buildAssistantMessage(completion.reply, { requestId, now });
const messages = [...previousMessages, userMessage, assistantMessage];
const messages = [...pendingMessages, assistantMessage];
const session = {
id: activeSessionId,
name: snapshot?.session?.name ?? 'New Chat',
@@ -232,9 +343,6 @@ export function createDirectChatService({
recipe: snapshot?.session?.recipe ?? null,
conversation: messages,
};
if (!sessionId) {
await userAuth.registerAgentSession(userId, activeSessionId, 'h5-direct');
}
await sessionSnapshotService.save(activeSessionId, userId, session, messages);
let billing = null;
@@ -264,6 +372,7 @@ export function createDirectChatService({
return {
getStatus,
canHandle,
explainCanHandle,
run,
};
}