From b8d4d2ac82f78cca57d9e55896039de3c114b148 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:55:07 -0500 Subject: [PATCH] Add pluggable memory embedder adapter and vector metadata --- src/services/memory/embedder.js | 114 +++++++++++++++++++++++++++++++- src/services/memory/ingestor.js | 50 +++++++++++--- test/memory-embedder.test.mjs | 39 +++++++++++ test/memory-ingestor.test.mjs | 18 +++++ 4 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 test/memory-embedder.test.mjs diff --git a/src/services/memory/embedder.js b/src/services/memory/embedder.js index 94b8aa8..e824446 100644 --- a/src/services/memory/embedder.js +++ b/src/services/memory/embedder.js @@ -1,3 +1,15 @@ +const DEFAULT_VECTOR_BACKEND = 'inmemory'; +const VECTOR_BACKEND_ENV = 'MEMORY_VECTOR_BACKEND'; + +/** + * @typedef {{ + * embedText: (text: string) => Promise | number[], + * upsertVector: (input: { id: string, vector: number[], metadata?: Record }) => Promise | void, + * queryVector: (input: { vector: number[], k?: number, filter?: Record }) => Promise }>> | Array<{ id: string, score: number, vector: number[], metadata: Record }>, + * deleteVector: (id: string) => Promise | boolean, + * }} EmbedderAdapter + */ + /** * @param {string} text * @returns {number[]} @@ -9,14 +21,112 @@ function fallbackEmbedding(text) { return [Number(mean.toFixed(4)), chars.length]; } +function cosineSimilarity(left, right) { + const size = Math.max(left.length, right.length); + let dot = 0; + let leftNorm = 0; + let rightNorm = 0; + + for (let index = 0; index < size; index += 1) { + const leftValue = Number(left[index] ?? 0); + const rightValue = Number(right[index] ?? 0); + dot += leftValue * rightValue; + leftNorm += leftValue ** 2; + rightNorm += rightValue ** 2; + } + + if (leftNorm === 0 || rightNorm === 0) return 0; + return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)); +} + +/** @implements {EmbedderAdapter} */ +class InMemoryEmbedderAdapter { + constructor() { + this.vectors = new Map(); + } + + /** @param {string} text */ + async embedText(text) { + return fallbackEmbedding(text); + } + + /** @param {{ id: string, vector: number[], metadata?: Record }} input */ + async upsertVector({ id, vector, metadata = {} }) { + if (typeof id !== 'string' || id.length === 0) { + throw new TypeError('upsertVector requires a non-empty id'); + } + if (!Array.isArray(vector) || vector.length === 0) { + throw new TypeError('upsertVector requires a non-empty vector'); + } + this.vectors.set(id, { vector: [...vector], metadata: { ...metadata } }); + } + + /** @param {{ vector: number[], k?: number, filter?: Record }} input */ + async queryVector({ vector, k = 5, filter = {} }) { + if (!Array.isArray(vector) || vector.length === 0) return []; + + const matchesFilter = (metadata) => Object.entries(filter).every(([key, value]) => metadata?.[key] === value); + + return [...this.vectors.entries()] + .filter(([, value]) => matchesFilter(value.metadata)) + .map(([id, value]) => ({ + id, + score: cosineSimilarity(vector, value.vector), + vector: [...value.vector], + metadata: { ...value.metadata }, + })) + .sort((left, right) => right.score - left.score) + .slice(0, Number.isFinite(k) && k > 0 ? Math.floor(k) : 5); + } + + /** @param {string} id */ + async deleteVector(id) { + if (typeof id !== 'string' || id.length === 0) return false; + return this.vectors.delete(id); + } +} + +const ADAPTER_FACTORIES = { + inmemory: () => new InMemoryEmbedderAdapter(), + local: () => new InMemoryEmbedderAdapter(), + mock: () => new InMemoryEmbedderAdapter(), +}; + +let defaultAdapter; + +/** + * @param {{ backend?: string }} [options] + * @returns {EmbedderAdapter} + */ +export function createEmbedderAdapter(options = {}) { + const configuredBackend = String(options.backend ?? process.env[VECTOR_BACKEND_ENV] ?? DEFAULT_VECTOR_BACKEND).trim().toLowerCase(); + const factory = ADAPTER_FACTORIES[configuredBackend]; + if (!factory) { + throw new Error(`Unsupported vector backend "${configuredBackend}". Configure ${VECTOR_BACKEND_ENV}=inmemory (default) until provider adapters are added.`); + } + return factory(); +} + +/** + * @returns {EmbedderAdapter} + */ +export function getDefaultEmbedderAdapter() { + if (!defaultAdapter) { + defaultAdapter = createEmbedderAdapter(); + } + return defaultAdapter; +} + /** * @param {string} text - * @param {{ embedText?: (value: string) => Promise | number[] }} [options] + * @param {{ embedText?: (value: string) => Promise | number[], adapter?: EmbedderAdapter }} [options] * @returns {Promise} */ export async function embedText(text, options = {}) { if (typeof options.embedText === 'function') { return options.embedText(text); } - return fallbackEmbedding(text); + + const adapter = options.adapter ?? getDefaultEmbedderAdapter(); + return adapter.embedText(text); } diff --git a/src/services/memory/ingestor.js b/src/services/memory/ingestor.js index d3dd135..5f9ac42 100644 --- a/src/services/memory/ingestor.js +++ b/src/services/memory/ingestor.js @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; import { createMemoryRecord, normalizeEvent, validateMemoryItem } from './schema.js'; -import { embedText } from './embedder.js'; +import { embedText, getDefaultEmbedderAdapter } from './embedder.js'; import { summarizeEvents } from './summarizer.js'; const WINDOW_BUCKET_MS = 60_000; @@ -88,6 +88,14 @@ async function rememberWindowFingerprint(repository, dedupeKey, payload) { } } + +function compactContextExcerpt(text, maxChars = 280) { + if (typeof text !== 'string' || text.length === 0) return ''; + const compact = text.replace(/\s+/g, ' ').trim(); + if (compact.length <= maxChars) return compact; + return `${compact.slice(0, maxChars - 1)}…`; +} + function buildTelemetryEmitter(options = {}) { if (typeof options.emitTelemetry === 'function') return options.emitTelemetry; if (typeof options.telemetry?.emit === 'function') { @@ -120,6 +128,9 @@ export function normalizeEvents(events) { * maxChunkChars?: number, * maxChunkTokens?: number, * embedText?: (value: string) => Promise | number[], + * adapter?: import('./embedder.js').EmbedderAdapter, + * embedderAdapter?: import('./embedder.js').EmbedderAdapter, + * contextExcerptChars?: number, * emitTelemetry?: (event: string, payload: Record) => void, * telemetry?: { emit: (event: string, payload: Record) => void }, * now?: number, @@ -184,7 +195,8 @@ export async function ingestMemoryWindow(input = {}, options = {}) { for (const chunk of chunks) { const summarization = await summarizeEvents(chunk, options); const contextText = chunk.map((event) => event.content).join('\n'); - const embedding = await embedText(`${summarization.summary}\n${contextText}`, options); + const summaryEmbedding = await embedText(summarization.summary, options); + const contextExcerpt = compactContextExcerpt(contextText, options.contextExcerptChars); const chunkStart = Math.min(...chunk.map((event) => event.timestamp)); const chunkEnd = Math.max(...chunk.map((event) => event.timestamp)); @@ -198,7 +210,7 @@ export async function ingestMemoryWindow(input = {}, options = {}) { importance: Number.isFinite(chunk[0]?.metadata?.importance) ? Number(chunk[0].metadata.importance) : summarization.importance, - embedding_id: Array.isArray(embedding) && embedding.length > 0 ? randomUUID() : null, + embedding_id: Array.isArray(summaryEmbedding) && summaryEmbedding.length > 0 ? randomUUID() : null, created_at: chunkStart, last_seen: chunkEnd, source: typeof chunk[0]?.metadata?.source === 'string' ? chunk[0].metadata.source : 'ingest', @@ -221,11 +233,33 @@ export async function ingestMemoryWindow(input = {}, options = {}) { await insertMemory(repository, record); insertedRecords.push(record); - if (record.embedding_id && typeof repository.linkEmbedding === 'function') { - await repository.linkEmbedding(record.id, { - id: record.embedding_id, - vector: embedding, - }); + if (record.embedding_id) { + const vectorMetadata = { + memory_id: record.id, + agent_id: record.agent_id, + summary: record.summary, + context_excerpt: contextExcerpt, + source: record.source, + created_at: record.created_at, + last_seen: record.last_seen, + }; + + if (typeof repository.linkEmbedding === 'function') { + await repository.linkEmbedding(record.id, { + id: record.embedding_id, + vector: summaryEmbedding, + metadata: vectorMetadata, + }); + } + + const adapter = options.embedderAdapter ?? options.adapter ?? getDefaultEmbedderAdapter(); + if (typeof adapter?.upsertVector === 'function') { + await adapter.upsertVector({ + id: record.embedding_id, + vector: summaryEmbedding, + metadata: vectorMetadata, + }); + } } emitTelemetry('memory:ingested', { diff --git a/test/memory-embedder.test.mjs b/test/memory-embedder.test.mjs new file mode 100644 index 0000000..36da65f --- /dev/null +++ b/test/memory-embedder.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createEmbedderAdapter, embedText } from '../src/services/memory/embedder.js'; + +test('createEmbedderAdapter selects in-memory backend and supports vector CRUD', async () => { + const adapter = createEmbedderAdapter({ backend: 'inmemory' }); + const vector = await adapter.embedText('hello world'); + + await adapter.upsertVector({ + id: 'vec-1', + vector, + metadata: { agent_id: 'a1', context_excerpt: 'hello world' }, + }); + + const results = await adapter.queryVector({ + vector, + k: 1, + filter: { agent_id: 'a1' }, + }); + + assert.equal(results.length, 1); + assert.equal(results[0].id, 'vec-1'); + assert.equal(results[0].metadata.context_excerpt, 'hello world'); + + const deleted = await adapter.deleteVector('vec-1'); + assert.equal(deleted, true); +}); + +test('createEmbedderAdapter reads backend from environment', async () => { + process.env.MEMORY_VECTOR_BACKEND = 'local'; + const adapter = createEmbedderAdapter(); + const vector = await embedText('env selected', { adapter }); + + assert.ok(Array.isArray(vector)); + assert.ok(vector.length > 0); + + delete process.env.MEMORY_VECTOR_BACKEND; +}); diff --git a/test/memory-ingestor.test.mjs b/test/memory-ingestor.test.mjs index 95a31a9..7e13435 100644 --- a/test/memory-ingestor.test.mjs +++ b/test/memory-ingestor.test.mjs @@ -7,6 +7,7 @@ test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, an const inserted = []; const linked = []; const telemetry = []; + const upserted = []; const repository = { async insertMemory(memory) { @@ -49,6 +50,20 @@ test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, an maxChunkChars: 40, embedText: (text) => [text.length], emitTelemetry: (event, payload) => telemetry.push({ event, payload }), + embedderAdapter: { + async embedText(text) { + return [text.length]; + }, + async upsertVector(entry) { + upserted.push(entry); + }, + async queryVector() { + return []; + }, + async deleteVector() { + return false; + }, + }, now: 2000, } ); @@ -57,9 +72,12 @@ test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, an assert.equal(inserted.length, 2); assert.equal(linked.length, 2); assert.equal(telemetry.length, 2); + assert.equal(upserted.length, 2); assert.ok(inserted.some((item) => item.content.includes('[redacted:email]'))); assert.ok(inserted.some((item) => item.content.includes('[redacted:phone]'))); assert.ok(telemetry.every((entry) => entry.event === 'memory:ingested')); + assert.ok(linked.every((entry) => typeof entry.embedding.metadata?.context_excerpt === 'string')); + assert.ok(linked.every((entry) => entry.embedding.metadata.context_excerpt.length > 0)); }); test('ingestMemoryWindow dedupes overlapping windows by hashed content + agent + timestamp bucket', async () => {