Add pluggable memory embedder adapter and vector metadata

This commit is contained in:
thePR0M3TH3AN committed 2026-02-14 14:55:07 -05:00
1 parent a1b6331cea
commit b8d4d2ac82
4 files changed
+211 -10

No files matched your search

+112 -2
View File
@@ -1,3 +1,15 @@
const DEFAULT_VECTOR_BACKEND = 'inmemory';
const VECTOR_BACKEND_ENV = 'MEMORY_VECTOR_BACKEND';
/**
* @typedef {{
* embedText: (text: string) => Promise<number[]> | number[],
* upsertVector: (input: { id: string, vector: number[], metadata?: Record<string, unknown> }) => Promise<void> | void,
* queryVector: (input: { vector: number[], k?: number, filter?: Record<string, unknown> }) => Promise<Array<{ id: string, score: number, vector: number[], metadata: Record<string, unknown> }>> | Array<{ id: string, score: number, vector: number[], metadata: Record<string, unknown> }>,
* deleteVector: (id: string) => Promise<boolean> | 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<string, unknown> }} 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<string, unknown> }} 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[]> | number[] }} [options]
* @param {{ embedText?: (value: string) => Promise<number[]> | number[], adapter?: EmbedderAdapter }} [options]
* @returns {Promise<number[]>}
*/
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);
}
+42 -8
View File
@@ -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[]> | number[],
* adapter?: import('./embedder.js').EmbedderAdapter,
* embedderAdapter?: import('./embedder.js').EmbedderAdapter,
* contextExcerptChars?: number,
* emitTelemetry?: (event: string, payload: Record<string, unknown>) => void,
* telemetry?: { emit: (event: string, payload: Record<string, unknown>) => 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', {
+39
View File
@@ -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;
});
+18
View File
@@ -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 () => {