/** * In-memory mock implementation of {@link MemoryBackend} — for unit tests. * * Enforces the SAME invariants as the real pgvector store so behavior * tests can run without Postgres: * * - writes validate path + scope via `assertWritablePath` * - agent-tier paths store with `userId = null` * - user-tier paths store with the caller's `userId` * - reads apply the layered filter * agent_id = $scope.agentId * AND (user_id IS NULL OR user_id = $scope.userId) * - UPSERT key is `(agentId, userId, path)` with NULL collisions * (so `NULLS NOT DISTINCT` semantics are reproduced in-memory) */ import type { MemoryAppendInput, MemoryBackend, MemoryEntry, MemoryGetOptions, MemoryHealth, MemoryReadResult, MemoryScope, MemorySearchOptions, } from '../types'; import { assertWritablePath, getTierForPath } from '../paths'; interface Row { id: string; agentId: string; /** null for agent-tier rows; caller id for user-tier rows. */ userId: string | null; /** Latest writer — provenance, tracked even on agent-tier rows. */ lastUserId: string | null; path: string; content: string; createdAt: Date; updatedAt: Date; } function assertScope(scope: MemoryScope): void { if (!scope || !scope.agentId) { throw new Error( 'MemoryScope { agentId } is required — agentId must be non-empty' ); } } function normalizeCallerId(scope: MemoryScope): string | null { const raw = scope.userId; if (raw == null || raw === '') return null; return String(raw); } /** Composite key matcher that collapses null/undefined to a single slot. */ function sameSlot( row: Row, agentId: string, userId: string | null, path: string ): boolean { return row.agentId === agentId && row.userId === userId && row.path === path; } export class MockMemoryBackend implements MemoryBackend { readonly kind = 'vector' as const; private rows: Row[] = []; private seq = 0; async search( scope: MemoryScope, query: string, opts?: MemorySearchOptions ): Promise { assertScope(scope); const callerId = normalizeCallerId(scope); const q = query.toLowerCase(); const hits = this.rows .filter((r) => r.agentId === scope.agentId) .filter((r) => r.userId === null || r.userId === callerId) .map((r) => { const content = r.content.toLowerCase(); const tokens = q.split(/\s+/).filter(Boolean); const score = tokens.length === 0 ? 0 : tokens.filter((t) => content.includes(t)).length / tokens.length; return { row: r, score }; }) .filter((h) => h.score >= (opts?.minScore ?? 0)) .sort((a, b) => b.score - a.score) .slice(0, opts?.maxResults ?? 10); return hits.map( ({ row, score }): MemoryEntry => ({ id: row.id, path: row.path, content: row.content, score, createdAt: row.createdAt, source: 'vector', tier: getTierForPath(row.path), }) ); } async get( scope: MemoryScope, opts: MemoryGetOptions ): Promise { assertScope(scope); if (!opts.path) return null; const callerId = normalizeCallerId(scope); const tier = getTierForPath(opts.path); const match = this.rows.find((r) => { if (r.agentId !== scope.agentId || r.path !== opts.path) return false; if (tier === 'agent') return r.userId === null; return r.userId === callerId; }); if (!match) return null; return { path: opts.path, text: match.content }; } async append(scope: MemoryScope, input: MemoryAppendInput): Promise { assertScope(scope); const descriptor = assertWritablePath(input.path, scope); if (!input.content.trim()) { throw new Error('memory_append content must be non-empty'); } const rowUserId = descriptor.tier === 'agent' ? null : String(scope.userId); const provenance = scope.userId != null ? String(scope.userId) : null; const existing = this.rows.find((r) => sameSlot(r, scope.agentId, rowUserId, input.path) ); if (existing) { existing.content = `${existing.content.replace(/\s+$/, '')}\n\n${input.content}`; existing.lastUserId = provenance; existing.updatedAt = new Date(); return; } this.rows.push({ id: String(++this.seq), agentId: scope.agentId, userId: rowUserId, lastUserId: provenance, path: input.path, content: input.content, createdAt: new Date(), updatedAt: new Date(), }); } async health(): Promise { return { ok: true, backend: 'vector' }; } /** Test-only introspection. */ allRows(): readonly Row[] { return this.rows; } }