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
+82 -4
View File
@@ -152,14 +152,16 @@ test('saveAndAnalyze stores messages and fallback memories', async () => {
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze leaves messages unanalyzed when extraction fails and no memory is stored', async () => {
test('saveAndAnalyze marks messages analyzed when llm extraction fails and no memory is stored', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const pool = createPool();
const service = createConversationMemoryService(pool, {
now: () => 2000,
fetch: async () => {
throw new Error('upstream unavailable');
llmProviderService: {
async createChatCompletion() {
throw new Error('upstream unavailable');
},
},
});
@@ -174,7 +176,83 @@ test('saveAndAnalyze leaves messages unanalyzed when extraction fails and no mem
assert.equal(result.saved, 1);
assert.equal(result.memories, 0);
assert.equal(pool.state.messages.find((item) => item.message_key === 'm3')?.analyzed_at, null);
assert.equal(pool.state.messages.find((item) => item.message_key === 'm3')?.analyzed_at, 2000);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze uses admin effective env for memory extraction model', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const pool = createPool();
const llmCalls = [];
const service = createConversationMemoryService(pool, {
now: () => 2600,
getEffectiveEnv: async () => ({
MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID: 'router-key',
MEMIND_CHAT_ROUTER_MODEL: 'deepseek-chat',
}),
llmProviderService: {
async createChatCompletion({ providerKeyId, model, messages }) {
llmCalls.push({ providerKeyId, model, messages });
return {
ok: true,
reply: JSON.stringify({
memories: [{ label: 'fact', text: '用户叫 John', confidence: 0.9 }],
}),
};
},
},
});
await service.saveAndAnalyze('session-admin', 'user-admin', [
{
id: 'm-admin',
role: 'user',
content: [{ type: 'text', text: '我是 John。' }],
},
]);
assert.equal(llmCalls.length, 1);
assert.equal(llmCalls[0].providerKeyId, 'router-key');
assert.equal(llmCalls[0].model, 'deepseek-chat');
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze extracts memories through llmProviderService', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const pool = createPool();
const service = createConversationMemoryService(pool, {
now: () => 2500,
llmProviderService: {
async createChatCompletion({ messages }) {
assert.match(String(messages?.[0]?.content ?? ''), /长期记忆/);
return {
ok: true,
reply: JSON.stringify({
memories: [{ label: 'goal', text: '用户计划去日本旅游', confidence: 0.82 }],
}),
};
},
},
});
const result = await service.saveAndAnalyze('session-llm', 'user-llm', [
{
id: 'm-japan',
role: 'user',
content: [{ type: 'text', text: '我打算带家人去日本玩 5 天。' }],
metadata: { userVisible: true },
},
]);
assert.equal(result.memories, 1);
assert.equal(pool.state.memories[0].label, 'goal');
assert.match(pool.state.memories[0].memory_text, /日本/);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;