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
+55 -12
View File
@@ -1,5 +1,5 @@
import crypto from 'node:crypto';
import { fetch as undiciFetch } from 'undici';
import { Agent, fetch as undiciFetch } from 'undici';
import { jsonrepair } from 'jsonrepair';
import {
decryptSecret,
@@ -7,6 +7,10 @@ import {
resolveChatCompletionsUrl,
} from './llm-providers.mjs';
const httpsDispatcher = new Agent({
connect: { rejectUnauthorized: false },
});
const MAX_MESSAGE_TEXT = 12000;
const MAX_ANALYSIS_MESSAGES = 8;
const MAX_MEMORY_TEXT = 1000;
@@ -131,8 +135,10 @@ function fallbackMemoriesFromMessages(messages) {
const patterns = [
{ label: 'preference', re: /(?:我喜欢|我偏好|我更喜欢|以后.*(?:用|叫|按)|不要再|别再)(.+)/ },
{ label: 'interest', re: /(?:我对|我关注|我感兴趣|我想了解)(.+)/ },
{ label: 'goal', re: /(?:我的目标是|我想要|我希望|我打算)(.+)/ },
{ label: 'goal', re: /(?:我的目标是|我想要|我希望|我打算|我计划|我想去|打算去|准备去)(.+)/ },
{ label: 'habit', re: /(?:我通常|我习惯|我一般)(.+)/ },
{ label: 'fact', re: /(?:我是|我叫|我来自|我在)(.{2,40})/ },
{ label: 'experience', re: /(?:我们|我).{0,8}(?:去|到|在).{2,40}(?:玩|旅游|旅行|出差|度假)/ },
];
for (const message of messages) {
const text = String(message.text ?? '').replace(/\s+/g, ' ').trim();
@@ -150,9 +156,33 @@ function fallbackMemoriesFromMessages(messages) {
return memories;
}
function resolveMemoryExtractionModelConfig(env = process.env) {
const providerKeyId = String(
env?.USER_CONVERSATION_MEMORY_MODEL_PROVIDER_KEY_ID
?? env?.MEMORY_EXTRACTION_MODEL_PROVIDER_KEY_ID
?? env?.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID
?? '',
).trim() || null;
const model = String(
env?.USER_CONVERSATION_MEMORY_MODEL
?? env?.MEMORY_EXTRACTION_MODEL
?? env?.MEMIND_CHAT_ROUTER_MODEL
?? '',
).trim() || null;
return { providerKeyId, model };
}
function parseExtractionMemories(rawText) {
const parsed = extractJsonObject(rawText);
const rawItems = Array.isArray(parsed?.memories) ? parsed.memories : [];
return rawItems.map((item) => normalizeMemoryItem(item)).filter(Boolean);
}
export function createConversationMemoryService(pool, options = {}) {
const now = options.now ?? (() => Date.now());
const fetchImpl = options.fetch ?? undiciFetch;
const llmProviderService = options.llmProviderService ?? null;
const resolveEffectiveEnv = options.getEffectiveEnv ?? (async () => process.env);
const encryptionKey = options.encryptionKey;
const llmWarningIntervalMs = Math.max(
0,
@@ -244,6 +274,22 @@ export function createConversationMemoryService(pool, options = {}) {
}
async function extractWithLlm(messages) {
const prompt = buildMemoryPrompt(messages);
const effectiveEnv = await resolveEffectiveEnv();
const { providerKeyId, model } = resolveMemoryExtractionModelConfig(effectiveEnv);
if (llmProviderService?.createChatCompletion) {
const completion = await llmProviderService.createChatCompletion({
providerKeyId: providerKeyId || undefined,
model: model || undefined,
temperature: 0,
messages: [{ role: 'user', content: prompt }],
});
if (!completion?.ok) return null;
const memories = parseExtractionMemories(completion.reply);
return memories.length ? memories : [];
}
const row = await selectedProvider();
if (!row) return null;
const apiUrl = normalizeApiUrl(row.api_url);
@@ -270,20 +316,19 @@ export function createConversationMemoryService(pool, options = {}) {
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: row.default_model,
messages: [{ role: 'user', content: buildMemoryPrompt(messages) }],
model: model || row.default_model,
messages: [{ role: 'user', content: prompt }],
stream: false,
temperature: 0,
...(row.relay_provider ? { provider: row.relay_provider } : {}),
}),
dispatcher: url.startsWith('https://') ? httpsDispatcher : undefined,
});
const text = await upstream.text().catch(() => '');
if (!upstream.ok) return null;
const payload = extractJsonObject(text);
const content = payload?.choices?.[0]?.message?.content ?? payload?.content ?? text;
const parsed = extractJsonObject(content);
const rawItems = Array.isArray(parsed?.memories) ? parsed.memories : [];
return rawItems.map((item) => normalizeMemoryItem(item)).filter(Boolean);
return parseExtractionMemories(content);
}
async function storeMemories(userId, sourceMessageIds, memories, rawJson = null) {
@@ -328,20 +373,18 @@ export function createConversationMemoryService(pool, options = {}) {
const messages = await loadUnanalyzedUserMessages(userId);
if (!messages.length) return { ok: true, analyzed: 0, memories: 0 };
let memories = null;
let extractionSucceeded = false;
if (llmEnabled()) {
try {
memories = await extractWithLlm(messages);
extractionSucceeded = Array.isArray(memories);
} catch (err) {
warnLlmExtractionFailed(err);
}
}
if (!memories) memories = fallbackMemoriesFromMessages(messages);
const stored = await storeMemories(userId, messages, memories);
if (!llmEnabled() || extractionSucceeded || stored > 0) {
await markAnalyzed(messages.map((message) => message.id));
}
// Always mark the batch processed after one pass so transient LLM failures
// do not leave messages permanently stuck in the analyze queue.
await markAnalyzed(messages.map((message) => message.id));
return { ok: true, analyzed: messages.length, memories: stored };
}