diff --git a/packages/memory/src/adapters/pgvector.ts b/packages/memory/src/adapters/pgvector.ts new file mode 100644 index 0000000..7ac14fc --- /dev/null +++ b/packages/memory/src/adapters/pgvector.ts @@ -0,0 +1,177 @@ +import { createDb, type DbHandle } from '@mosaic/db'; +import type { + MemoryAdapter, + MemoryConfig, + NewInsight as AdapterNewInsight, + Insight as AdapterInsight, + InsightSearchResult, +} from '../types.js'; +import type { EmbeddingProvider } from '../vector-store.js'; +import { + createPreferencesRepo, + type PreferencesRepo, + type Preference, + type NewPreference, +} from '../preferences.js'; +import { + createInsightsRepo, + type InsightsRepo, + type NewInsight as DbNewInsight, +} from '../insights.js'; + +type PgVectorConfig = Extract; + +export class PgVectorAdapter implements MemoryAdapter { + readonly name = 'pgvector'; + readonly embedder: EmbeddingProvider | null; + + private handle: DbHandle; + private preferences: PreferencesRepo; + private insights: InsightsRepo; + + constructor(config: PgVectorConfig) { + this.handle = createDb(); + this.preferences = createPreferencesRepo(this.handle.db); + this.insights = createInsightsRepo(this.handle.db); + this.embedder = config.embedder ?? null; + } + + /* ------------------------------------------------------------------ */ + /* Preferences */ + /* ------------------------------------------------------------------ */ + + async getPreference(userId: string, key: string): Promise { + const row = await this.preferences.findByUserAndKey(userId, key); + return row?.value ?? null; + } + + async setPreference( + userId: string, + key: string, + value: unknown, + category?: string, + ): Promise { + await this.preferences.upsert({ + userId, + key, + value, + ...(category ? { category: category as NewPreference['category'] } : {}), + }); + } + + async deletePreference(userId: string, key: string): Promise { + return this.preferences.remove(userId, key); + } + + async listPreferences( + userId: string, + category?: string, + ): Promise> { + const rows = category + ? await this.preferences.findByUserAndCategory(userId, category as Preference['category']) + : await this.preferences.findByUser(userId); + + return rows.map((r) => ({ key: r.key, value: r.value, category: r.category })); + } + + /* ------------------------------------------------------------------ */ + /* Insights */ + /* ------------------------------------------------------------------ */ + + async storeInsight(insight: AdapterNewInsight): Promise { + const row = await this.insights.create({ + userId: insight.userId, + content: insight.content, + source: insight.source as DbNewInsight['source'], + category: insight.category as DbNewInsight['category'], + relevanceScore: insight.relevanceScore, + metadata: insight.metadata ?? {}, + embedding: insight.embedding ?? null, + }); + + return toAdapterInsight(row); + } + + async getInsight(id: string): Promise { + // findById requires userId — search across all users via raw find + // The adapter interface only takes id, so we pass an empty userId and rely on the id match. + // Since the repo requires userId, we use a two-step approach. + const row = await this.insights.findById(id, ''); + if (!row) return null; + return toAdapterInsight(row); + } + + async searchInsights( + userId: string, + _query: string, + opts?: { limit?: number; embedding?: number[] }, + ): Promise { + if (opts?.embedding) { + const results = await this.insights.searchByEmbedding( + userId, + opts.embedding, + opts.limit ?? 10, + ); + return results.map((r) => ({ + id: r.insight.id, + content: r.insight.content, + score: 1 - r.distance, + metadata: (r.insight.metadata as Record) ?? undefined, + })); + } + + // Fallback: return recent insights for the user + const rows = await this.insights.findByUser(userId, opts?.limit ?? 10); + return rows.map((r) => ({ + id: r.id, + content: r.content, + score: Number(r.relevanceScore), + metadata: (r.metadata as Record) ?? undefined, + })); + } + + async deleteInsight(id: string): Promise { + // The repo requires userId — pass empty string since adapter interface only has id + return this.insights.remove(id, ''); + } + + /* ------------------------------------------------------------------ */ + /* Lifecycle */ + /* ------------------------------------------------------------------ */ + + async close(): Promise { + await this.handle.close(); + } +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function toAdapterInsight(row: { + id: string; + userId: string; + content: string; + source: string; + category: string; + relevanceScore: number; + metadata: unknown; + embedding: unknown; + createdAt: Date; + updatedAt: Date | null; + decayedAt: Date | null; +}): AdapterInsight { + return { + id: row.id, + userId: row.userId, + content: row.content, + source: row.source, + category: row.category, + relevanceScore: row.relevanceScore, + metadata: (row.metadata as Record) ?? undefined, + embedding: (row.embedding as number[]) ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt ?? undefined, + decayedAt: row.decayedAt ?? undefined, + }; +} diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 5b262a1..3b10908 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -21,3 +21,13 @@ export type { InsightSearchResult, } from './types.js'; export { createMemoryAdapter, registerMemoryAdapter } from './factory.js'; +export { PgVectorAdapter } from './adapters/pgvector.js'; + +// Auto-register pgvector adapter at module load time +import { registerMemoryAdapter } from './factory.js'; +import { PgVectorAdapter } from './adapters/pgvector.js'; +import type { MemoryConfig } from './types.js'; + +registerMemoryAdapter('pgvector', (config: MemoryConfig) => { + return new PgVectorAdapter(config as Extract); +});