diff --git a/capabilities.mjs b/capabilities.mjs index a909ac6..38aa004 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -45,6 +45,13 @@ export function resolveSandboxMcpNodeExecPath(overridePath) { const LOOPBACK_PG_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); +export function isChatRecallExtensionSupported(env = process.env) { + const sessionDbUrl = String(env?.GOOSE_SESSION_DB_URL ?? '').trim().toLowerCase(); + if (!sessionDbUrl) return true; + // Goose chatrecall still uses SQLite transaction syntax when persisting extension state. + return !sessionDbUrl.startsWith('postgres'); +} + function rewritePgUnixSocketUrlForContainer(sourceUrl, hostGateway) { const raw = String(sourceUrl ?? '').trim(); const authorityMatch = raw.match(/^(postgres(?:ql)?:\/\/[^@/]+@)\/(.*)$/i); @@ -739,7 +746,7 @@ export function buildAgentExtensionPolicy( if (capabilities.code_sandbox && enableCodeExecutionExtension) { extensions.push(makeExtension('platform', 'code_execution', [])); } - if (capabilities.chat_recall) { + if (capabilities.chat_recall && isChatRecallExtensionSupported(process.env)) { extensions.push(makeExtension('platform', 'chatrecall', [])); } if ( diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 1fa4ced..bb9afcf 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -17,6 +17,7 @@ import { resolveSandboxMcpControlDbEnv, sandboxDeveloperTools, sandboxMcpTools, + isChatRecallExtensionSupported, withoutSessionImageRead, } from './capabilities.mjs'; import { @@ -202,6 +203,26 @@ test('default user policy blocks dangerous capabilities', () => { assert.equal(DEFAULT_USER_CAPABILITIES.openhands, false); }); +test('isChatRecallExtensionSupported disables chatrecall on postgres session storage', () => { + assert.equal(isChatRecallExtensionSupported({}), true); + assert.equal( + isChatRecallExtensionSupported({ GOOSE_SESSION_DB_URL: 'postgresql://john@127.0.0.1:5432/memind_sessions' }), + false, + ); +}); + +test('buildAgentExtensionPolicy omits chatrecall when postgres session storage is configured', () => { + const previous = process.env.GOOSE_SESSION_DB_URL; + process.env.GOOSE_SESSION_DB_URL = 'postgresql://john@127.0.0.1:5432/memind_sessions'; + try { + const policy = buildAgentExtensionPolicy(DEFAULT_USER_CAPABILITIES); + assert.equal(policy.extensionOverrides.some((ext) => ext.name === 'chatrecall'), false); + } finally { + if (previous == null) delete process.env.GOOSE_SESSION_DB_URL; + else process.env.GOOSE_SESSION_DB_URL = previous; + } +}); + test('buildAgentExtensionPolicy returns null overrides and auto mode for unrestricted users', () => { const policy = buildAgentExtensionPolicy({}, { unrestricted: true }); assert.equal(policy.extensionOverrides, null); diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 7459134..4ec0899 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -17,6 +17,7 @@ import { import { filterMemoriesByQuery } from './memory-legacy-fallback.mjs'; import { matchDirectChatFaqRule } from './chat-intent-router-rules.mjs'; import { isGoalRunIntent } from './goal-run-intent.mjs'; +import { pgvectorMemoryBackendInternals } from './memory-v2-pgvector.mjs'; export { matchDirectChatFaqRule, DIRECT_CHAT_FAQ_RULES, FAQ_EXCLUSION_PATTERNS } from './chat-intent-router-rules.mjs'; @@ -207,6 +208,61 @@ export function isMemoryRecallQuestion(text) { return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized)); } +function memoryItemText(item) { + return String(item?.text ?? item?.memory_text ?? item?.content ?? '').trim(); +} + +function isEpisodicMemoryItem(item) { + if (!item || typeof item !== 'object') return false; + if (item.source === 'episodic' || item.source === 'episodic-index') return true; + if (item.sessionId != null && String(item.sessionId).trim()) return true; + return String(item.label ?? '').trim() === '历史会话'; +} + +export function scoreAgentMemoryCandidate(item, query) { + const text = memoryItemText(item); + if (!text) return 0; + const lexical = pgvectorMemoryBackendInternals.lexicalQueryCoverage(query, text); + let keywordBoost = 0; + for (const term of pgvectorMemoryBackendInternals.extractKeywordTerms(query)) { + if (text.includes(term)) keywordBoost += term.length; + } + let score = (lexical * 100) + keywordBoost; + if (isEpisodicMemoryItem(item) && score > 0) score += 5; + return score; +} + +export function mergeAgentMemoryCandidates({ + personalMemories = [], + episodicMemories = [], + query = '', + limit = 3, +} = {}) { + const ranked = [ + ...(Array.isArray(episodicMemories) ? episodicMemories : []), + ...(Array.isArray(personalMemories) ? personalMemories : []), + ] + .map((item, index) => ({ + item, + index, + score: scoreAgentMemoryCandidate(item, query), + })) + .sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return left.index - right.index; + }); + const memories = []; + const seen = new Set(); + for (const { item } of ranked) { + const key = String(item?.id ?? item?.sessionId ?? memoryItemText(item)).trim(); + if (!key || seen.has(key)) continue; + seen.add(key); + memories.push(item); + if (memories.length >= limit) break; + } + return memories; +} + export function isAgentSessionContinueText(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; @@ -1338,18 +1394,12 @@ export function createChatIntentRouter(options = {}) { agentMemoryPolicy.timeoutMs, 'Memory V2 agent resolve', ); - const memories = []; - const seen = new Set(); - for (const item of [ - ...(Array.isArray(episodicResolved?.memories) ? episodicResolved.memories : []), - ...(Array.isArray(personalResolved?.memories) ? personalResolved.memories : []), - ]) { - const key = String(item?.id ?? item?.sessionId ?? item?.text ?? item?.memory_text ?? '').trim(); - if (!key || seen.has(key)) continue; - seen.add(key); - memories.push(item); - if (memories.length >= limit) break; - } + const memories = mergeAgentMemoryCandidates({ + personalMemories: personalResolved?.memories, + episodicMemories: episodicResolved?.memories, + query: text, + limit, + }); const degraded = personalFailed || episodicFailed || Boolean(personalResolved?.degraded) diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index b16315a..a15dcfe 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -29,6 +29,8 @@ import { isChatLlmRouterEligible, logChatLlmRouterShadow, resolveChatIntentRouterPolicy, + mergeAgentMemoryCandidates, + scoreAgentMemoryCandidate, } from './chat-intent-router.mjs'; /** Ambiguous user text that should miss FAQ/rules and exercise LLM router paths in tests. */ @@ -1361,6 +1363,41 @@ test('agent memory recall prioritizes historical session evidence over personal assert.equal(episodicCalls[0].sessionId, 'current-session'); }); +test('agent memory merge prefers relevant personal memories over weak episodic matches', () => { + const merged = mergeAgentMemoryCandidates({ + query: '你记得我们聊过德川家康吗', + limit: 2, + episodicMemories: [{ + id: 'episodic:news', + label: '历史会话', + text: '会话“每日新闻页面”:用户要求整理今天国际国内热点新闻并做成页面。', + }], + personalMemories: [{ + id: 'personal:tokugawa', + label: 'interest', + text: '用户对日本战国人物德川家康感兴趣,并希望继续深入讨论', + }], + }); + + assert.equal(merged[0].id, 'personal:tokugawa'); + assert.equal(merged[1].id, 'episodic:news'); +}); + +test('scoreAgentMemoryCandidate boosts episodic matches when topic overlap exists', () => { + const episodicScore = scoreAgentMemoryCandidate({ + id: 'episodic:old-session', + label: '历史会话', + sessionId: 'old-session', + text: '会话“日本战国史”:用户与助手讨论过德川家康。', + }, '我们之前聊过德川家康,你还记得吗?'); + const personalScore = scoreAgentMemoryCandidate({ + id: 'personal-1', + label: '偏好', + text: '用户喜欢历史话题', + }, '我们之前聊过德川家康,你还记得吗?'); + assert.ok(episodicScore > personalScore); +}); + test('active agent memory context is hidden from displayText but available to orchestration envelope', () => { const enriched = applyAgentOrchestrationToUserMessage( { diff --git a/memory-v2-pgvector.mjs b/memory-v2-pgvector.mjs index 535276b..966e1a0 100644 --- a/memory-v2-pgvector.mjs +++ b/memory-v2-pgvector.mjs @@ -53,6 +53,62 @@ function lexicalQueryCoverage(query, text) { return overlap / queryGrams.size; } +const KEYWORD_STOP_TERMS = new Set([ + '记得', '忘记', '之前', '我们', '聊过', '讨论', '继续', '聊聊', '什么', '吗', '呢', + '有没有', '是否', '告诉', '提到', '说过', '以前', '上次', '对话', '会话', '回忆', + '搜索', '帮助', '可以', '一下', '还是', '然后', '现在', '今天', '晚上', '你好', +]); + +export function extractKeywordTerms(query, { maxTerms = 8, minLength = 2 } = {}) { + const normalized = String(query ?? '').normalize('NFKC').trim(); + if (!normalized) return []; + const terms = new Set(); + const cjkOnly = normalized.replace(/[^\u4e00-\u9fff]/gu, ''); + for (let index = 0; index < cjkOnly.length; index += 1) { + for (const size of [4, 3, 2]) { + if (index + size > cjkOnly.length) continue; + const term = cjkOnly.slice(index, index + size); + if (term.length >= minLength && !KEYWORD_STOP_TERMS.has(term)) { + terms.add(term); + } + } + } + for (const match of normalized.matchAll(/[a-z0-9]{3,}/gi)) { + terms.add(match[0].toLowerCase()); + } + return [...terms] + .sort((left, right) => right.length - left.length) + .slice(0, maxTerms); +} + +function dedupeContentPrefix(text, length = 96) { + return normalizeSearchText(String(text ?? '').slice(0, length)); +} + +async function fetchKeywordCandidates(pool, { + userId, + query, + tableName, + limit = 20, + maxTerms = 8, +} = {}) { + const terms = extractKeywordTerms(query, { maxTerms }); + if (!terms.length) return []; + const clauses = terms.map((_term, index) => `content ILIKE $${index + 2}`); + const params = [userId, ...terms.map((term) => `%${term}%`)]; + const sql = ` + SELECT id, content, type, created_at, updated_at, 1.0 AS score + FROM ${tableName} + WHERE user_id = $1 + AND (${clauses.join(' OR ')}) + ORDER BY updated_at DESC + LIMIT $${params.length + 1} + `; + params.push(Math.max(1, Math.min(50, Number(limit) || 20))); + const result = await pool.query(sql, params); + return result?.rows ?? []; +} + function timestampValue(value) { if (value == null) return 0; const numeric = Number(value); @@ -76,9 +132,13 @@ function normalizeRow(row) { function rankHybridCandidates(rows, query, limit) { const byId = new Map(); + const byPrefix = new Set(); for (const row of rows ?? []) { const memory = normalizeRow(row); if (!memory) continue; + const prefixKey = dedupeContentPrefix(memory.text); + if (prefixKey && byPrefix.has(prefixKey)) continue; + if (prefixKey) byPrefix.add(prefixKey); const key = memory.id ?? `${memory.label}:${memory.text}`; if (!byId.has(key)) byId.set(key, memory); } @@ -171,8 +231,20 @@ export function createPgvectorMemoryBackend({ ) AS candidates ORDER BY id, source_priority `; - const result = await pool.query(sql, [userId, vectorLiteral(embedding), candidateLimit]); - const memories = rankHybridCandidates(result?.rows ?? [], input.query, limit); + const [result, keywordRows] = await Promise.all([ + pool.query(sql, [userId, vectorLiteral(embedding), candidateLimit]), + fetchKeywordCandidates(pool, { + userId, + query: input.query, + tableName: resolvedTableName, + limit: Math.max(limit, candidateLimit), + }).catch(() => []), + ]); + const memories = rankHybridCandidates( + [...(result?.rows ?? []), ...keywordRows], + input.query, + limit, + ); return { semanticMemories: memories.map((item) => item.text), memories, @@ -184,4 +256,6 @@ export function createPgvectorMemoryBackend({ export const pgvectorMemoryBackendInternals = { lexicalQueryCoverage, rankHybridCandidates, + extractKeywordTerms, + dedupeContentPrefix, }; diff --git a/memory-v2-pgvector.test.mjs b/memory-v2-pgvector.test.mjs index ae4434f..7b4c94a 100644 --- a/memory-v2-pgvector.test.mjs +++ b/memory-v2-pgvector.test.mjs @@ -86,11 +86,12 @@ test('pgvector backend performs parameterized vector lookup when explicitly enab limit: 5, }); - assert.equal(queries.length, 1); + assert.equal(queries.length, 2); assert.match(queries[0].sql, /FROM memory_embeddings/); assert.match(queries[0].sql, /WITH vector_candidates/); assert.match(queries[0].sql, /recent_candidates/); assert.deepEqual(queries[0].params, ['user-1', '[0.25,0.5,0.75]', 50]); + assert.match(queries[1].sql, /ILIKE/); assert.deepEqual(result.semanticMemories, ['用户关注 Memory V2 的 facade 边界']); assert.deepEqual(result.memories, [ { @@ -161,6 +162,58 @@ test('pgvector hybrid ranking keeps vector order when query has no lexical overl assert.equal(ranked[1].id, '1'); }); +test('pgvector keyword fallback retrieves topic memories missed by vector top-k', async () => { + const queries = []; + const backend = createPgvectorMemoryBackend({ + enabled: true, + pool: { + async query(sql, params) { + queries.push({ sql, params }); + if (String(sql).includes('ILIKE')) { + return { + rows: [{ + id: 901, + content: '用户对日本战国人物德川家康感兴趣,并希望继续深入讨论', + type: 'interest', + score: 1, + created_at: '2026-08-01T13:57:00.000Z', + updated_at: '2026-08-01T13:57:00.000Z', + }], + }; + } + return { + rows: [{ + id: 1, + content: '用户以后只要说“帮我搜索今天国际国内热门新闻和小知识,做成页面”', + type: 'preference', + score: 0.91, + created_at: '2026-07-31T00:00:00.000Z', + updated_at: '2026-07-31T00:00:00.000Z', + }], + }; + }, + }, + embedQuery: async () => [0.25, 0.5, 0.75], + }); + + const result = await backend.resolve({ + userId: 'user-tang', + query: '我们继续聊聊德川家康', + limit: 2, + }); + + assert.match(result.memories[0].text, /德川家康/); + assert.equal(queries.some((entry) => String(entry.sql).includes('ILIKE')), true); +}); + +test('extractKeywordTerms keeps topic phrases and drops recall boilerplate', () => { + const terms = pgvectorMemoryBackendInternals.extractKeywordTerms('我们之前有聊过,你记得吗'); + assert.equal(terms.includes('记得'), false); + assert.equal(terms.includes('我们'), false); + const topicTerms = pgvectorMemoryBackendInternals.extractKeywordTerms('我们继续聊聊德川家康'); + assert.equal(topicTerms.some((term) => term.includes('德川')), true); +}); + test('pgvector backend validates table names before building SQL', () => { assert.throws( () => createPgvectorMemoryBackend({ tableName: 'memory_embeddings;DROP TABLE users' }), diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index bafc501..46a71da 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -167,6 +167,7 @@ export async function bootstrapPortalIntegrationServices({ llmProviderService, chatIntentRouter, wechatIntentRouter, + mysqlPool: pool, systemDisclosurePolicyService, sessionIntentClassifier: ({ text }) => chatIntentRouter?.classifySessionAction({ diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 505bca3..7c5b319 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -37,6 +37,10 @@ import { isWechatContentEditFollowup, } from './wechat/intent/page-continuation.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; +import { + MEMORY_V2_PRODUCT_EVENT_TYPES, + recordMemoryV2ProductEvent, +} from './memory-v2-product-events.mjs'; import { buildWechatImageRunMetadata, resolveWechatImageGenerationPolicy, @@ -1488,6 +1492,7 @@ export function createWechatMpService({ llmProviderService = null, chatIntentRouter = null, wechatIntentRouter = null, + mysqlPool = null, systemDisclosurePolicyService = null, onPageGenerated = null, applySessionLlmProvider = null, @@ -2358,6 +2363,28 @@ export function createWechatMpService({ ) { return userMessage; } + const memoryCount = memoryContext.memories.length; + if (mysqlPool?.query) { + void recordMemoryV2ProductEvent(mysqlPool, { + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.RESOLVED_INJECTED, + userId, + sessionId, + data: { + mode: memoryContext.mode ?? null, + memoryCount, + source: memoryContext.source ?? 'wechat_mp', + }, + }).catch(() => {}); + void recordMemoryV2ProductEvent(mysqlPool, { + eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.RECALL_HIT, + userId, + sessionId, + data: { + memoryCount, + mode: memoryContext.mode ?? null, + }, + }).catch(() => {}); + } const orchestrationMessage = preserveAgentPrompt ? { ...userMessage,