fix(memory-v2): improve pgvector recall and WeChat memory injection

Add keyword fallback and dedupe for pgvector resolve, score personal vs episodic memories by relevance, record WeChat recall product events, and disable broken chatrecall on Postgres session storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 22:26:47 +08:00
parent dac06753ce
commit dcf83e9b6f
8 changed files with 286 additions and 16 deletions
+8 -1
View File
@@ -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 (
+21
View File
@@ -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);
+62 -12
View File
@@ -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)
+37
View File
@@ -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(
{
+76 -2
View File
@@ -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,
};
+54 -1
View File
@@ -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' }),
@@ -167,6 +167,7 @@ export async function bootstrapPortalIntegrationServices({
llmProviderService,
chatIntentRouter,
wechatIntentRouter,
mysqlPool: pool,
systemDisclosurePolicyService,
sessionIntentClassifier: ({ text }) =>
chatIntentRouter?.classifySessionAction({
+27
View File
@@ -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,