import assert from 'node:assert/strict'; import test from 'node:test'; import { createMemoryV2 } from './memory-v2.mjs'; import { createPgvectorMemoryBackend, pgvectorMemoryBackendInternals, } from './memory-v2-pgvector.mjs'; test('pgvector backend is disabled by default and does not query storage', async () => { let queried = false; const backend = createPgvectorMemoryBackend({ pool: { async query() { queried = true; return { rows: [] }; }, }, }); assert.equal(backend.isAvailable(), false); assert.deepEqual(await backend.resolve({ userId: 'user-1', embedding: [0.1, 0.2], }), { memories: [], semanticMemories: [], }); assert.equal(queried, false); }); test('pgvector backend returns empty semantic result when embedding is unavailable', async () => { let queried = false; const backend = createPgvectorMemoryBackend({ enabled: true, pool: { async query() { queried = true; return { rows: [] }; }, }, }); const result = await backend.resolve({ userId: 'user-1', query: 'memory-chain', }); assert.deepEqual(result, { memories: [], semanticMemories: [], }); assert.equal(queried, false); }); test('pgvector backend performs parameterized vector lookup when explicitly enabled', async () => { const queries = []; const backend = createPgvectorMemoryBackend({ enabled: true, tableName: 'memory_embeddings', pool: { async query(sql, params) { queries.push({ sql, params }); return { rows: [ { id: 7, content: '用户关注 Memory V2 的 facade 边界', type: 'fact', score: '0.87', created_at: '2026-07-02T00:00:00.000Z', }, ], }; }, }, embedQuery: async (query) => { assert.equal(query, 'memory-chain'); return [0.25, 0.5, 0.75]; }, }); assert.equal(backend.isAvailable(), true); const result = await backend.resolve({ userId: 'user-1', query: 'memory-chain', limit: 5, }); assert.equal(queries.length, 2); assert.match(queries[0].sql, /FROM memory_embeddings/); assert.match(queries[0].sql, /WITH top_score/); assert.match(queries[0].sql, /vector_candidates/); assert.match(queries[0].sql, /recent_candidates/); assert.deepEqual(queries[0].params, ['user-1', '[0.25,0.5,0.75]', 50, 0.15, 200]); assert.match(queries[1].sql, /ILIKE/); assert.deepEqual(result.semanticMemories, ['用户关注 Memory V2 的 facade 边界']); assert.deepEqual(result.memories, [ { id: '7', label: 'fact', text: '用户关注 Memory V2 的 facade 边界', score: 0.87, createdAt: '2026-07-02T00:00:00.000Z', updatedAt: '2026-07-02T00:00:00.000Z', }, ]); }); test('pgvector hybrid ranking recovers a recent Chinese memory missed by vector top-k', async () => { const marker = 'MEM-RECALL-NEW'; const backend = createPgvectorMemoryBackend({ enabled: true, pool: { async query() { return { rows: [ { id: 1, content: '用户以前关注贵州旅游攻略', type: 'interest', score: 0.91, created_at: '2026-06-01T00:00:00.000Z', updated_at: '2026-06-01T00:00:00.000Z', }, { id: 2, content: `用户的记忆召回灰度测试代号是 ${marker}`, type: 'fact', score: -0.49, created_at: '2026-07-22T00:00:00.000Z', updated_at: '2026-07-22T00:00:00.000Z', }, { id: 3, content: '用户偏好简洁回答', type: 'preference', score: 0.3, created_at: '2026-07-21T00:00:00.000Z', updated_at: '2026-07-21T00:00:00.000Z', }, ], }; }, }, embedQuery: async () => [0.25, 0.5, 0.75], }); const result = await backend.resolve({ userId: 'user-1', query: '我之前让你记住的记忆召回灰度测试代号是什么?请只回答完整代号。', limit: 3, }); assert.match(result.memories[0].text, new RegExp(marker)); }); test('recallRankingWeights boosts vector only when semantic spread is visible', () => { const { recallRankingWeights } = pgvectorMemoryBackendInternals; const flat = recallRankingWeights('Why did Sarah close the curtains?', [ { vectorScore: 0.41 }, { vectorScore: 0.39 }, { vectorScore: 0.38 }, ]); assert.deepEqual(flat, { lexical: 1, vector: 1 }); const semantic = recallRankingWeights('Why did Sarah close the curtains?', [ { vectorScore: 0.82 }, { vectorScore: 0.55 }, { vectorScore: 0.41 }, ]); assert.deepEqual(semantic, { lexical: 0.45, vector: 1.55 }); }); test('pgvector RRF hybrid ranking promotes semantic vector match over topical noise', () => { const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([ { id: 'noise', content: 'The curtains the curtains the curtains were recently updated in the living room', score: 0.42, }, { id: 'gold', content: 'Sarah closed the smart curtains to reduce pollen entry', score: 0.86, }, ], 'Why did Sarah close the curtains?', 1); assert.equal(ranked[0].id, 'gold'); }); test('pgvector keyword-only rows do not pollute vector RRF ranks', () => { const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([ { id: 'keyword-noise', content: 'The curtains the curtains the curtains were recently updated in the living room', score: null, }, { id: 'vector-gold', content: 'Sarah closed the smart curtains to reduce pollen entry', score: 0.86, }, ], 'Why did Sarah close the curtains?', 1); assert.equal(ranked[0].id, 'vector-gold'); }); test('pgvector semantic spread attenuates zero-overlap vector-only noise', () => { const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([ { id: 'vector-noise', content: 'Ambient living room humidity sensor calibration report for May', score: 0.91, }, { id: 'weak-overlap-gold', content: 'David reported his back felt sore after the morning stretch routine', score: 0.68, }, ], "How was David's back today?", 1); assert.equal(ranked[0].id, 'weak-overlap-gold'); }); test('pgvector hybrid ranking keeps vector order when query has no lexical overlap', () => { const ranked = pgvectorMemoryBackendInternals.rankHybridCandidates([ { id: 1, content: 'alpha', score: 0.2 }, { id: 2, content: 'beta', score: 0.8 }, ], '完全无关的中文查询', 2); assert.equal(ranked[0].id, '2'); assert.equal(ranked[1].id, '1'); }); test('pgvector keyword fallback ranks ILIKE matches by lexical coverage, not recency', async () => { const backend = createPgvectorMemoryBackend({ enabled: true, pool: { async query(sql) { if (String(sql).includes('ILIKE')) { assert.doesNotMatch(String(sql), /ORDER BY updated_at DESC/i); return { rows: [ { id: 902, content: 'Recent but weak match for curtains only', type: 'noise', score: 1, created_at: '2026-09-01T00:00:00.000Z', updated_at: '2026-09-01T00:00:00.000Z', }, { id: 901, content: 'Sarah closed the smart curtains to reduce pollen entry', type: 'fact', score: 1, created_at: '2026-05-01T00:00:00.000Z', updated_at: '2026-05-01T00:00:00.000Z', }, ], }; } return { rows: [] }; }, }, embedQuery: async () => [0.25, 0.5, 0.75], }); const result = await backend.resolve({ userId: 'user-1', query: 'Why did Sarah close the curtains?', limit: 1, }); assert.match(result.memories[0].text, /Sarah closed the smart curtains/); }); 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('latinWordQueryCoverage ranks topical English content over bigram noise', () => { const { latinWordQueryCoverage, queryScriptProfile } = pgvectorMemoryBackendInternals; const query = 'Why did Sarah close the curtains?'; assert.equal(queryScriptProfile(query), 'latin'); const gold = 'Sarah closed the smart curtains to reduce pollen entry'; const noise = 'The curtains the curtains the curtains were recently updated'; assert.ok( latinWordQueryCoverage(query, gold) > latinWordQueryCoverage(query, noise), ); }); test('extractKeywordTerms drops English recall boilerplate', () => { const terms = pgvectorMemoryBackendInternals.extractKeywordTerms('Why did Sarah close the curtains?'); assert.equal(terms.includes('why'), false); assert.equal(terms.includes('did'), false); assert.equal(terms.includes('the'), false); assert.equal(terms.includes('sarah'), true); assert.equal(terms.includes('curtains'), true); }); test('extractKeywordTerms prioritizes proper nouns over generic English terms', () => { const terms = pgvectorMemoryBackendInternals.extractKeywordTerms( 'Can you piece together what happened with Ethan from soccer practice until he got home?', ); assert.equal(terms[0], 'ethan'); assert.equal(terms.includes('together'), false); assert.equal(terms.includes('happened'), false); assert.equal(terms.includes('soccer'), true); }); test('mergeKeywordTerms prioritizes recallContext device tokens', () => { const { mergeKeywordTerms } = pgvectorMemoryBackendInternals; const terms = mergeKeywordTerms( 'What happened this morning?', { device: 'phone_sarah', user: 'Sarah' }, { maxTerms: 8 }, ); assert.equal(terms.includes('sarah'), true); assert.equal(terms.includes('phone'), true); assert.ok(terms.indexOf('phone') < terms.indexOf('morning')); }); test('latinWordQueryCoverage boosts tagged device tokens in source prefix', () => { const { latinWordQueryCoverage } = pgvectorMemoryBackendInternals; const query = 'What did Ethan do at soccer practice?'; const tagged = '[watch_ethan · field] Ethan fell during soccer practice'; const untagged = 'Ethan fell during soccer practice'; assert.ok(latinWordQueryCoverage(query, tagged) >= latinWordQueryCoverage(query, untagged)); }); test('selectKeywordCandidateRows keeps priority-term gold under broad OR truncation', () => { const { selectKeywordCandidateRows, extractKeywordTerms } = pgvectorMemoryBackendInternals; const query = 'Why did David promise Ethan extra LEGO time on weekends?'; const terms = extractKeywordTerms(query); const goldId = 'gold-lego'; const rows = [ { id: goldId, content: 'David promised Ethan extra LEGO time on weekends during recovery' }, ...Array.from({ length: 900 }, (_entry, index) => ({ id: `noise-${index}`, content: `David mentioned schedule item ${index} for the household calendar update`, })), ]; const selected = selectKeywordCandidateRows(rows, query, terms, { returnLimit: 50 }); assert.ok(selected.some((row) => row.id === goldId)); }); test('selectVectorCandidateRows expands margin band beyond fixed top-N', () => { const { selectVectorCandidateRows } = pgvectorMemoryBackendInternals; const scored = [ { row: { id: 'top' }, score: 0.9 }, ...Array.from({ length: 120 }, (_entry, index) => ({ row: { id: `filler-${index}` }, score: 0.85 - index * 0.001, })), { row: { id: 'near-gold' }, score: 0.79 }, ]; const selected = selectVectorCandidateRows(scored, { baseLimit: 100, margin: 0.12, expandCap: 200 }); const ids = selected.map((entry) => entry.row.id); assert.ok(ids.includes('near-gold')); assert.ok(!ids.includes('filler-119') || ids.includes('near-gold')); }); test('pgvector backend validates table names before building SQL', () => { assert.throws( () => createPgvectorMemoryBackend({ tableName: 'memory_embeddings;DROP TABLE users' }), /Invalid pgvector table name/, ); }); test('Memory V2 can expose pgvector as unavailable plugin without selecting it', async () => { const memory = createMemoryV2({ logger: { warn() {} }, backends: [ createPgvectorMemoryBackend({ enabled: false }), { name: 'legacy-conversation-memory', async resolve() { return { memories: [{ label: 'fact', text: 'legacy survives' }] }; }, }, ], env: { MEMORY_ENABLED: '1', MEMORY_BACKEND: 'pgvector', MEMORY_VECTOR_ENABLED: '1', }, }); const status = memory.getStatus(); const result = await memory.resolve({ userId: 'user-1', query: 'memory-chain' }); assert.equal(status.vectorEnabled, true); assert.equal(status.selectedBackend, 'legacy-conversation-memory'); assert.equal(status.backends.find((item) => item.name === 'pgvector')?.available, false); assert.deepEqual(result.memories, [{ label: 'fact', text: 'legacy survives' }]); });