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:
+55
-12
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user