/** * Postgres + pgvector implementation of {@link MemoryBackend}. * * ## Scoping model (two-tier, layered) * * Every read query applies the layered filter * * WHERE agent_id = $1 AND (user_id IS NULL OR user_id = $2) * * so the caller sees: * - agent-tier rows (`user_id IS NULL`) — shared operational knowledge, * visible to every user of the agent * - their own user-tier rows (`user_id = `) — private per-user * personalization * * Another user's user-tier rows are invisible — the privacy boundary is * enforced in SQL, not just in the UI or route layer. * * ## Writes * * `append()` routes to a row based on the path's tier, resolved via * {@link assertWritablePath}: * * - `memory/agent/*` → stored with `user_id = NULL` regardless of * what scope the caller passed. Agent-tier content is inherently * shared; scoping it per-user would defeat the point. * - `memory/user/*` → stored with `user_id = scope.userId`. A missing * `scope.userId` throws — user-tier paths cannot be written from * isolated/autonomous contexts. * * UPSERT key is `(agent_id, user_id, path)` with `NULLS NOT DISTINCT`, * so each user gets their own `user/preferences.md` row and there is * exactly one `agent/playbook.md` row shared across the whole user base. * Content accumulates via `\n\n` concatenation on conflict, with the * embedding regenerated over the merged content so search stays * consistent. */ import type { Pool } from 'pg'; import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_MEMORY_TABLE, DEFAULT_MIN_SCORE, HYBRID_TEXT_WEIGHT, HYBRID_VECTOR_WEIGHT, } from './constants'; import { getMemoryEmbedder, type EmbeddingProvider } from './embeddings'; import { applyMMRToMemoryHits } from './mmr'; import { applyTemporalDecayToHits } from './temporalDecay'; import { decorateCitations, shouldIncludeCitations } from './citations'; import { assertWritablePath, getTierForPath } from './paths'; import type { MemoryAppendInput, MemoryBackend, MemoryEntry, MemoryGetOptions, MemoryHealth, MemoryReadResult, MemoryScope, MemorySearchOptions, } from './types'; export interface PgvectorStoreOptions { pool: Pool; table?: string; embedder?: EmbeddingProvider; } function assertScope(scope: MemoryScope): void { if (!scope || !scope.agentId) { throw new Error( 'MemoryScope { agentId } is required — agentId must be non-empty' ); } } /** pgvector literal format: "[0.1,0.2,...]". */ function toVectorLiteral(vec: number[]): string { return `[${vec.join(',')}]`; } /** * Normalize caller userId for the layered read filter. * * The SQL filter is `(user_id IS NULL OR user_id = $2)`, so an empty * string from the caller must not match rows whose user_id was set. * We coerce empty/null/undefined to `null`, and pg treats `$2 = null` * as `false` — which is exactly what we want for isolated callers: * they see only agent-tier rows and nothing in the user tier. */ function normalizeCallerId(scope: MemoryScope): string | null { const raw = scope.userId; if (raw == null || raw === '') return null; return String(raw); } export class PgvectorMemoryStore implements MemoryBackend { readonly kind = 'vector' as const; private pool: Pool; private table: string; private embedder: EmbeddingProvider; constructor(opts: PgvectorStoreOptions) { this.pool = opts.pool; this.table = opts.table ?? DEFAULT_MEMORY_TABLE; this.embedder = opts.embedder ?? getMemoryEmbedder(); } async search( scope: MemoryScope, query: string, opts: MemorySearchOptions = {} ): Promise { assertScope(scope); const trimmed = query.trim(); if (!trimmed) return []; const maxResults = Math.max( 1, opts.maxResults ?? DEFAULT_MAX_SEARCH_RESULTS ); const minScore = opts.minScore ?? DEFAULT_MIN_SCORE; const vector = await this.embedder.embed(trimmed); const vectorLiteral = toVectorLiteral(vector); const callerId = normalizeCallerId(scope); // [memory-layered-search] debug: layered scope filter // agent-tier rows (user_id IS NULL) + this caller's user-tier rows. // Another user's rows are invisible at the SQL layer. const sql = ` WITH scored AS ( SELECT id, path, content, created_at, user_id, (1 - (embedding <=> $1::vector)) AS vector_score, ts_rank(tsv, plainto_tsquery('english', $2)) AS text_score FROM ${this.table} WHERE agent_id = $3 AND (user_id IS NULL OR user_id = $4) ) SELECT id, path, content, created_at, user_id, (${HYBRID_VECTOR_WEIGHT} * vector_score + ${HYBRID_TEXT_WEIGHT} * text_score) AS score FROM scored WHERE (${HYBRID_VECTOR_WEIGHT} * vector_score + ${HYBRID_TEXT_WEIGHT} * text_score) >= $5 ORDER BY score DESC LIMIT $6 `; const { rows } = await this.pool.query(sql, [ vectorLiteral, trimmed, scope.agentId, callerId, minScore, maxResults, ]); let hits: MemoryEntry[] = rows.map( (row: { id: string | number; path: string; content: string; created_at: Date; user_id: string | null; score: string | number; }): MemoryEntry => ({ id: String(row.id), path: row.path, content: row.content, createdAt: new Date(row.created_at), score: Number(row.score), source: 'vector', tier: getTierForPath(row.path), }) ); // Phase 2: temporal decay (before MMR so diversity sees post-decay ranks) if (opts.temporalDecay?.enabled) { hits = applyTemporalDecayToHits(hits, opts.temporalDecay); hits.sort((a, b) => b.score - a.score); } // Phase 2: MMR reranking if (opts.mmr?.enabled) { hits = applyMMRToMemoryHits(hits, opts.mmr); } // Phase 2: citations (decorate last — mutates content with Source: trailer) const citationsMode = opts.citations ?? 'auto'; if (shouldIncludeCitations(citationsMode)) { hits = decorateCitations(hits, true); } return hits; } async get( scope: MemoryScope, opts: MemoryGetOptions ): Promise { assertScope(scope); if (!opts.path) return null; const callerId = normalizeCallerId(scope); const tier = getTierForPath(opts.path); // Agent-tier rows always live under user_id=NULL. User-tier rows // always carry the caller's id. Querying with a precise predicate // is faster than leaving it open AND guarantees a user cannot // read another user's row even if they know the path by heart. const userClause = tier === 'agent' ? 'user_id IS NULL' : 'user_id = $3'; const params: unknown[] = [scope.agentId, opts.path]; if (tier === 'user') params.push(callerId); const sql = ` SELECT content FROM ${this.table} WHERE agent_id = $1 AND path = $2 AND ${userClause} ORDER BY updated_at DESC LIMIT 1 `; const { rows } = await this.pool.query(sql, params); if (rows.length === 0) return null; const text = String(rows[0].content ?? ''); if (opts.from == null && opts.lines == null) { return { path: opts.path, text }; } const allLines = text.split('\n'); const fromIdx = Math.max(0, (opts.from ?? 1) - 1); const count = Math.max(0, opts.lines ?? allLines.length - fromIdx); const slice = allLines.slice(fromIdx, fromIdx + count).join('\n'); return { path: opts.path, text: slice }; } async append(scope: MemoryScope, input: MemoryAppendInput): Promise { assertScope(scope); // Whitelist + tier + scope-compatibility check in one call. Throws // with an actionable message for each failure mode. const descriptor = assertWritablePath(input.path, scope); const content = input.content.trim(); if (!content) { throw new Error('memory_append content must be non-empty'); } // Tier determines the row's user_id: // agent tier → NULL (shared across all users) // user tier → the caller's id (assertWritablePath guarantees non-empty) const rowUserId = descriptor.tier === 'agent' ? null : String(scope.userId); const provenance = scope.userId != null ? String(scope.userId) : null; // Read the existing row (if any) for THIS tier so we can embed the // merged content. Agent-tier merges regardless of caller; user-tier // merges only within the caller's own row. const lookupSql = ` SELECT content FROM ${this.table} WHERE agent_id = $1 AND path = $2 AND ${rowUserId === null ? 'user_id IS NULL' : 'user_id = $3'} LIMIT 1 `; const lookupParams: unknown[] = rowUserId === null ? [scope.agentId, input.path] : [scope.agentId, input.path, rowUserId]; const existing = await this.pool.query(lookupSql, lookupParams); const priorContent: string = existing.rows.length > 0 ? String(existing.rows[0].content ?? '') : ''; const mergedContent = priorContent ? `${priorContent.replace(/\s+$/, '')}\n\n${content}` : content; const vector = await this.embedder.embed(mergedContent); const vectorLiteral = toVectorLiteral(vector); // UPSERT on (agent_id, user_id, path) with NULLS NOT DISTINCT so // two NULL user_ids collide on the same agent+path (exactly one // agent-tier row) while per-user rows on the same path coexist. // // Provenance note: `last_user_id` always records WHO wrote the // latest append, even for agent-tier rows where the row's own // `user_id` stays NULL. That gives the admin UI an audit trail // ("agent-tier row last updated by Alice") without changing the // scoping semantics. const upsertSql = ` INSERT INTO ${this.table} (agent_id, user_id, path, content, embedding, last_user_id, updated_at) VALUES ($1, $2, $3, $4, $5::vector, $6, NOW()) ON CONFLICT (agent_id, user_id, path) DO UPDATE SET content = EXCLUDED.content, embedding = EXCLUDED.embedding, last_user_id = EXCLUDED.last_user_id, updated_at = NOW() `; await this.pool.query(upsertSql, [ scope.agentId, rowUserId, input.path, mergedContent, vectorLiteral, provenance, ]); } async health(): Promise { try { await this.pool.query('SELECT 1'); return { ok: true, backend: 'vector' }; } catch (err) { return { ok: false, backend: 'vector', error: err instanceof Error ? err.message : String(err), }; } } }