diff --git a/packages/memory/package.json b/packages/memory/package.json index dddd74f..d08e1c0 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@mosaic/db": "workspace:*", + "@mosaic/storage": "workspace:*", "@mosaic/types": "workspace:*", "drizzle-orm": "^0.45.1" }, diff --git a/packages/memory/src/adapters/keyword.test.ts b/packages/memory/src/adapters/keyword.test.ts new file mode 100644 index 0000000..ad691da --- /dev/null +++ b/packages/memory/src/adapters/keyword.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { StorageAdapter } from '@mosaic/storage'; +import { KeywordAdapter } from './keyword.js'; + +/* ------------------------------------------------------------------ */ +/* In-memory mock StorageAdapter */ +/* ------------------------------------------------------------------ */ + +function createMockStorage(): StorageAdapter { + const collections = new Map>>(); + let idCounter = 0; + + function getCollection(name: string): Map> { + if (!collections.has(name)) collections.set(name, new Map()); + return collections.get(name)!; + } + + const adapter: StorageAdapter = { + name: 'mock', + + async create>( + collection: string, + data: T, + ): Promise { + const id = String(++idCounter); + const record = { ...data, id }; + getCollection(collection).set(id, record); + return record as T & { id: string }; + }, + + async read>( + collection: string, + id: string, + ): Promise { + const record = getCollection(collection).get(id); + return (record as T) ?? null; + }, + + async update(collection: string, id: string, data: Record): Promise { + const col = getCollection(collection); + const existing = col.get(id); + if (!existing) return false; + col.set(id, { ...existing, ...data }); + return true; + }, + + async delete(collection: string, id: string): Promise { + return getCollection(collection).delete(id); + }, + + async find>( + collection: string, + filter?: Record, + ): Promise { + const col = getCollection(collection); + const results: T[] = []; + for (const record of col.values()) { + if (filter && !matchesFilter(record, filter)) continue; + results.push(record as T); + } + return results; + }, + + async findOne>( + collection: string, + filter: Record, + ): Promise { + const col = getCollection(collection); + for (const record of col.values()) { + if (matchesFilter(record, filter)) return record as T; + } + return null; + }, + + async count(collection: string, filter?: Record): Promise { + const rows = await adapter.find(collection, filter); + return rows.length; + }, + + async transaction(fn: (tx: StorageAdapter) => Promise): Promise { + return fn(adapter); + }, + + async migrate(): Promise {}, + async close(): Promise {}, + }; + + return adapter; +} + +function matchesFilter(record: Record, filter: Record): boolean { + for (const [key, value] of Object.entries(filter)) { + if (record[key] !== value) return false; + } + return true; +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe('KeywordAdapter', () => { + let adapter: KeywordAdapter; + + beforeEach(() => { + adapter = new KeywordAdapter({ type: 'keyword', storage: createMockStorage() }); + }); + + /* ---- Preferences ---- */ + + describe('preferences', () => { + it('should set and get a preference', async () => { + await adapter.setPreference('u1', 'theme', 'dark'); + const value = await adapter.getPreference('u1', 'theme'); + expect(value).toBe('dark'); + }); + + it('should return null for missing preference', async () => { + const value = await adapter.getPreference('u1', 'nonexistent'); + expect(value).toBeNull(); + }); + + it('should upsert an existing preference', async () => { + await adapter.setPreference('u1', 'theme', 'dark'); + await adapter.setPreference('u1', 'theme', 'light'); + const value = await adapter.getPreference('u1', 'theme'); + expect(value).toBe('light'); + }); + + it('should delete a preference', async () => { + await adapter.setPreference('u1', 'theme', 'dark'); + const deleted = await adapter.deletePreference('u1', 'theme'); + expect(deleted).toBe(true); + const value = await adapter.getPreference('u1', 'theme'); + expect(value).toBeNull(); + }); + + it('should return false when deleting nonexistent preference', async () => { + const deleted = await adapter.deletePreference('u1', 'nope'); + expect(deleted).toBe(false); + }); + + it('should list preferences by userId', async () => { + await adapter.setPreference('u1', 'theme', 'dark', 'appearance'); + await adapter.setPreference('u1', 'lang', 'en', 'locale'); + await adapter.setPreference('u2', 'theme', 'light', 'appearance'); + + const prefs = await adapter.listPreferences('u1'); + expect(prefs).toHaveLength(2); + expect(prefs.map((p) => p.key).sort()).toEqual(['lang', 'theme']); + }); + + it('should filter preferences by category', async () => { + await adapter.setPreference('u1', 'theme', 'dark', 'appearance'); + await adapter.setPreference('u1', 'lang', 'en', 'locale'); + + const prefs = await adapter.listPreferences('u1', 'appearance'); + expect(prefs).toHaveLength(1); + expect(prefs[0]!.key).toBe('theme'); + }); + }); + + /* ---- Insights ---- */ + + describe('insights', () => { + it('should store and retrieve an insight', async () => { + const insight = await adapter.storeInsight({ + userId: 'u1', + content: 'TypeScript is great for type safety', + source: 'chat', + category: 'technical', + relevanceScore: 0.9, + }); + + expect(insight.id).toBeDefined(); + expect(insight.content).toBe('TypeScript is great for type safety'); + + const fetched = await adapter.getInsight(insight.id); + expect(fetched).not.toBeNull(); + expect(fetched!.content).toBe('TypeScript is great for type safety'); + }); + + it('should return null for missing insight', async () => { + const result = await adapter.getInsight('nonexistent'); + expect(result).toBeNull(); + }); + + it('should delete an insight', async () => { + const insight = await adapter.storeInsight({ + userId: 'u1', + content: 'test', + source: 'chat', + category: 'general', + relevanceScore: 0.5, + }); + + const deleted = await adapter.deleteInsight(insight.id); + expect(deleted).toBe(true); + + const fetched = await adapter.getInsight(insight.id); + expect(fetched).toBeNull(); + }); + }); + + /* ---- Keyword Search ---- */ + + describe('searchInsights', () => { + beforeEach(async () => { + await adapter.storeInsight({ + userId: 'u1', + content: 'TypeScript provides excellent type safety for JavaScript projects', + source: 'chat', + category: 'technical', + relevanceScore: 0.9, + }); + await adapter.storeInsight({ + userId: 'u1', + content: 'React hooks simplify state management in components', + source: 'chat', + category: 'technical', + relevanceScore: 0.8, + }); + await adapter.storeInsight({ + userId: 'u1', + content: 'TypeScript and React work great together for type safe components', + source: 'chat', + category: 'technical', + relevanceScore: 0.85, + }); + await adapter.storeInsight({ + userId: 'u2', + content: 'TypeScript is popular', + source: 'chat', + category: 'general', + relevanceScore: 0.5, + }); + }); + + it('should find insights by exact keyword', async () => { + const results = await adapter.searchInsights('u1', 'hooks'); + expect(results).toHaveLength(1); + expect(results[0]!.content).toContain('hooks'); + }); + + it('should be case-insensitive', async () => { + const results = await adapter.searchInsights('u1', 'TYPESCRIPT'); + expect(results.length).toBeGreaterThanOrEqual(1); + for (const r of results) { + expect(r.content.toLowerCase()).toContain('typescript'); + } + }); + + it('should rank multi-word matches higher', async () => { + const results = await adapter.searchInsights('u1', 'TypeScript React'); + // The insight mentioning both "TypeScript" and "React" should rank first (score=2) + expect(results[0]!.score).toBe(2); + expect(results[0]!.content).toContain('TypeScript'); + expect(results[0]!.content).toContain('React'); + }); + + it('should return empty for no matches', async () => { + const results = await adapter.searchInsights('u1', 'python django'); + expect(results).toHaveLength(0); + }); + + it('should filter by userId', async () => { + const results = await adapter.searchInsights('u2', 'TypeScript'); + expect(results).toHaveLength(1); + expect(results[0]!.content).toBe('TypeScript is popular'); + }); + + it('should respect limit option', async () => { + const results = await adapter.searchInsights('u1', 'TypeScript', { limit: 1 }); + expect(results).toHaveLength(1); + }); + + it('should return empty for empty query', async () => { + const results = await adapter.searchInsights('u1', ' '); + expect(results).toHaveLength(0); + }); + }); + + /* ---- Lifecycle ---- */ + + describe('lifecycle', () => { + it('should have name "keyword"', () => { + expect(adapter.name).toBe('keyword'); + }); + + it('should have null embedder', () => { + expect(adapter.embedder).toBeNull(); + }); + + it('should close without error', async () => { + await expect(adapter.close()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/memory/src/adapters/keyword.ts b/packages/memory/src/adapters/keyword.ts new file mode 100644 index 0000000..ee079a1 --- /dev/null +++ b/packages/memory/src/adapters/keyword.ts @@ -0,0 +1,195 @@ +import type { StorageAdapter } from '@mosaic/storage'; +import type { + MemoryAdapter, + MemoryConfig, + NewInsight, + Insight, + InsightSearchResult, +} from '../types.js'; +import type { EmbeddingProvider } from '../vector-store.js'; + +type KeywordConfig = Extract; + +const PREFERENCES = 'preferences'; +const INSIGHTS = 'insights'; + +type PreferenceRecord = Record & { + id: string; + userId: string; + key: string; + value: unknown; + category: string; +}; + +type InsightRecord = Record & { + id: string; + userId: string; + content: string; + source: string; + category: string; + relevanceScore: number; + metadata: Record; + createdAt: string; + updatedAt?: string; + decayedAt?: string; +}; + +export class KeywordAdapter implements MemoryAdapter { + readonly name = 'keyword'; + readonly embedder: EmbeddingProvider | null = null; + + private storage: StorageAdapter; + + constructor(config: KeywordConfig) { + this.storage = config.storage; + } + + /* ------------------------------------------------------------------ */ + /* Preferences */ + /* ------------------------------------------------------------------ */ + + async getPreference(userId: string, key: string): Promise { + const row = await this.storage.findOne(PREFERENCES, { userId, key }); + return row?.value ?? null; + } + + async setPreference( + userId: string, + key: string, + value: unknown, + category?: string, + ): Promise { + const existing = await this.storage.findOne(PREFERENCES, { userId, key }); + if (existing) { + await this.storage.update(PREFERENCES, existing.id, { + value, + ...(category !== undefined ? { category } : {}), + }); + } else { + await this.storage.create(PREFERENCES, { + userId, + key, + value, + category: category ?? 'general', + }); + } + } + + async deletePreference(userId: string, key: string): Promise { + const existing = await this.storage.findOne(PREFERENCES, { userId, key }); + if (!existing) return false; + return this.storage.delete(PREFERENCES, existing.id); + } + + async listPreferences( + userId: string, + category?: string, + ): Promise> { + const filter: Record = { userId }; + if (category !== undefined) filter.category = category; + + const rows = await this.storage.find(PREFERENCES, filter); + return rows.map((r) => ({ key: r.key, value: r.value, category: r.category })); + } + + /* ------------------------------------------------------------------ */ + /* Insights */ + /* ------------------------------------------------------------------ */ + + async storeInsight(insight: NewInsight): Promise { + const now = new Date(); + const row = await this.storage.create>(INSIGHTS, { + userId: insight.userId, + content: insight.content, + source: insight.source, + category: insight.category, + relevanceScore: insight.relevanceScore, + metadata: insight.metadata ?? {}, + createdAt: now.toISOString(), + }); + + return { + id: row.id, + userId: insight.userId, + content: insight.content, + source: insight.source, + category: insight.category, + relevanceScore: insight.relevanceScore, + metadata: insight.metadata, + createdAt: now, + }; + } + + async getInsight(id: string): Promise { + const row = await this.storage.read(INSIGHTS, id); + if (!row) return null; + return toInsight(row); + } + + async searchInsights( + userId: string, + query: string, + opts?: { limit?: number; embedding?: number[] }, + ): Promise { + const limit = opts?.limit ?? 10; + const words = query + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 0); + + if (words.length === 0) return []; + + const rows = await this.storage.find(INSIGHTS, { userId }); + + const scored: InsightSearchResult[] = []; + for (const row of rows) { + const content = row.content.toLowerCase(); + let score = 0; + for (const word of words) { + if (content.includes(word)) score++; + } + if (score > 0) { + scored.push({ + id: row.id, + content: row.content, + score, + metadata: row.metadata ?? undefined, + }); + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit); + } + + async deleteInsight(id: string): Promise { + return this.storage.delete(INSIGHTS, id); + } + + /* ------------------------------------------------------------------ */ + /* Lifecycle */ + /* ------------------------------------------------------------------ */ + + async close(): Promise { + // no-op — storage adapter manages its own lifecycle + } +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function toInsight(row: InsightRecord): Insight { + return { + id: row.id, + userId: row.userId, + content: row.content, + source: row.source, + category: row.category, + relevanceScore: row.relevanceScore, + metadata: row.metadata ?? undefined, + createdAt: new Date(row.createdAt), + updatedAt: row.updatedAt ? new Date(row.updatedAt) : undefined, + decayedAt: row.decayedAt ? new Date(row.decayedAt) : undefined, + }; +} diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 3b10908..bcbd5dd 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -22,12 +22,18 @@ export type { } from './types.js'; export { createMemoryAdapter, registerMemoryAdapter } from './factory.js'; export { PgVectorAdapter } from './adapters/pgvector.js'; +export { KeywordAdapter } from './adapters/keyword.js'; -// Auto-register pgvector adapter at module load time +// Auto-register adapters at module load time import { registerMemoryAdapter } from './factory.js'; import { PgVectorAdapter } from './adapters/pgvector.js'; +import { KeywordAdapter } from './adapters/keyword.js'; import type { MemoryConfig } from './types.js'; registerMemoryAdapter('pgvector', (config: MemoryConfig) => { return new PgVectorAdapter(config as Extract); }); + +registerMemoryAdapter('keyword', (config: MemoryConfig) => { + return new KeywordAdapter(config as Extract); +}); diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts index cddc2a6..034cf04 100644 --- a/packages/memory/src/types.ts +++ b/packages/memory/src/types.ts @@ -1,5 +1,6 @@ export type { EmbeddingProvider, VectorSearchResult } from './vector-store.js'; import type { EmbeddingProvider } from './vector-store.js'; +import type { StorageAdapter } from '@mosaic/storage'; /* ------------------------------------------------------------------ */ /* Insight types (adapter-level, decoupled from Drizzle schema) */ @@ -69,4 +70,4 @@ export interface MemoryAdapter { export type MemoryConfig = | { type: 'pgvector'; embedder?: EmbeddingProvider } | { type: 'sqlite-vec'; embedder?: EmbeddingProvider } - | { type: 'keyword' }; + | { type: 'keyword'; storage: StorageAdapter }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e23bb05..d03125d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,6 +456,9 @@ importers: '@mosaic/db': specifier: workspace:* version: link:../db + '@mosaic/storage': + specifier: workspace:* + version: link:../storage '@mosaic/types': specifier: workspace:* version: link:../types @@ -634,10 +637,10 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.48 @@ -6999,12 +7002,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@anthropic-ai/sdk@0.73.0(zod@3.25.76)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 3.25.76 - '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -8589,18 +8586,6 @@ snapshots: - ws - zod - '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8661,30 +8646,6 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@anthropic-ai/sdk': 0.73.0(zod@3.25.76) - '@aws-sdk/client-bedrock-runtime': 3.1008.0 - '@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) - '@mistralai/mistralai': 1.14.1 - '@sinclair/typebox': 0.34.48 - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - chalk: 5.6.2 - openai: 6.26.0(ws@8.20.0)(zod@3.25.76) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - undici: 7.24.3 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -12903,11 +12864,6 @@ snapshots: dependencies: mimic-function: 5.0.1 - openai@6.26.0(ws@8.20.0)(zod@3.25.76): - optionalDependencies: - ws: 8.20.0 - zod: 3.25.76 - openai@6.26.0(ws@8.20.0)(zod@4.3.6): optionalDependencies: ws: 8.20.0