/** * Composite memory backend — the architectural seam for a future graph layer. * * Today, memory is pgvector only. Tomorrow, a Graphiti or Neo4j-agent-memory * adapter can implement the same {@link MemoryBackend} interface and be * composed with the vector store here — without touching the tool * definitions, prompts, or host wiring. * * Fan-out strategy (simple on purpose for Phase 1): * - `search`: query every backend in parallel, merge, dedupe by id, sort by * score, cap at maxResults. Graph hits and vector hits are interleaved by * score — the LLM sees one ranked list. * - `get`: the vector store owns file paths; graph stores don't. First * backend that returns a non-null result wins. In practice this will almost * always be the vector store, since append paths live there. * - `append`: fan out to every backend. Vector store persists the note, * graph backend runs its own extraction pipeline. An append failure in any * single backend is surfaced — we fail loud rather than silently dropping * writes. */ import type { MemoryAppendInput, MemoryBackend, MemoryEntry, MemoryGetOptions, MemoryHealth, MemoryReadResult, MemoryScope, MemorySearchOptions, } from './types'; export class CompositeMemoryBackend implements MemoryBackend { readonly kind = 'composite' as const; private readonly backends: MemoryBackend[]; constructor(backends: MemoryBackend[]) { if (backends.length === 0) { throw new Error('CompositeMemoryBackend requires at least one backend'); } this.backends = backends; } async search( scope: MemoryScope, query: string, opts?: MemorySearchOptions ): Promise { const perBackend = await Promise.all( this.backends.map((backend) => backend.search(scope, query, opts)) ); const merged = new Map(); for (const entries of perBackend) { for (const entry of entries) { const key = `${entry.source ?? 'unknown'}:${entry.id}`; const existing = merged.get(key); if (!existing || entry.score > existing.score) { merged.set(key, entry); } } } const limit = Math.max(1, opts?.maxResults ?? 10); return Array.from(merged.values()) .sort((a, b) => b.score - a.score) .slice(0, limit); } async get( scope: MemoryScope, opts: MemoryGetOptions ): Promise { for (const backend of this.backends) { const result = await backend.get(scope, opts); if (result) return result; } return null; } async append(scope: MemoryScope, input: MemoryAppendInput): Promise { // Serial fan-out — a failure in an earlier backend means we stop and // bubble. We do NOT want a partial write where the vector row landed but // the graph backend quietly dropped the note. for (const backend of this.backends) { await backend.append(scope, input); } } async health(): Promise { const healths = await Promise.all(this.backends.map((b) => b.health())); const failed = healths.find((h) => !h.ok); if (failed) { return { ok: false, backend: 'composite', error: `${failed.backend}: ${failed.error ?? 'unhealthy'}`, }; } return { ok: true, backend: 'composite' }; } }