// Session memory operations for persistent learnings import { query, queryOne, execute } from '../db.js'; import { getEmbedding, formatEmbedding, generateContentHash } from '../embeddings.js'; type MemoryCategory = 'pattern' | 'fix' | 'preference' | 'gotcha' | 'architecture'; interface Memory { id: number; category: MemoryCategory; title: string; content: string; context: string | null; project: string | null; session_id: string | null; task_id: string | null; access_count: number; created_at: string; } interface MemoryAddArgs { category: MemoryCategory; title: string; content: string; context?: string; project?: string; session_id?: string; task_id?: string; } interface MemorySearchArgs { query: string; project?: string; category?: MemoryCategory; limit?: number; } interface MemoryListArgs { project?: string; category?: MemoryCategory; limit?: number; } /** * Add a new memory/learning (enhanced with session_id and task_id) * CF-306: Validates session_id exists before inserting to prevent foreign key violations */ export async function memoryAdd(args: MemoryAddArgs): Promise { const { category, title, content, context, project, session_id, task_id } = args; // CF-306: Validate session_id exists if provided let validSessionId = session_id || null; if (session_id) { const sessionExists = await queryOne<{ exists: boolean }>( `SELECT EXISTS(SELECT 1 FROM sessions WHERE id = $1) as exists`, [session_id] ); if (!sessionExists?.exists) { console.warn(`[CF-306] Session ${session_id} not found in database - using NULL instead`); validSessionId = null; } } // CF-1314: Hash content for dedup before embedding API call const embedText = `${title}. ${content}`; const contentHash = generateContentHash(embedText); // Scope dedup to project if provided, otherwise global const existing = project ? await queryOne<{ id: number }>( 'SELECT id FROM memories WHERE content_hash = $1 AND project = $2 LIMIT 1', [contentHash, project] ) : await queryOne<{ id: number }>( 'SELECT id FROM memories WHERE content_hash = $1 AND project IS NULL LIMIT 1', [contentHash] ); if (existing) { return `Memory already exists (id: ${existing.id}): [${category}] ${title}`; } // Generate embedding for semantic search const embedding = await getEmbedding(embedText); const embeddingValue = embedding ? formatEmbedding(embedding) : null; await execute( `INSERT INTO memories (category, title, content, context, project, session_id, task_id, embedding, content_hash) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [category, title, content, context || null, project || null, validSessionId, task_id || null, embeddingValue, contentHash] ); return `Stored memory: [${category}] ${title}`; } /** * Search memories semantically */ export async function memorySearch(args: MemorySearchArgs): Promise { const { query: searchQuery, project, category, limit = 5 } = args; // Generate embedding for search const embedding = await getEmbedding(searchQuery); if (!embedding) { return 'Error: Could not generate embedding for search'; } const embeddingStr = formatEmbedding(embedding); let whereClause = 'WHERE embedding IS NOT NULL'; const params: unknown[] = [embeddingStr, limit]; let paramIndex = 3; if (project) { whereClause += ` AND (project = $${paramIndex++} OR project IS NULL)`; params.splice(params.length - 1, 0, project); } if (category) { whereClause += ` AND category = $${paramIndex++}`; params.splice(params.length - 1, 0, category); } const memories = await query( `SELECT id, category, title, content, context, project, access_count, to_char(created_at, 'YYYY-MM-DD') as created_at, 1 - (embedding <=> $1) as similarity FROM memories ${whereClause} ORDER BY embedding <=> $1 LIMIT $2`, params ); if (memories.length === 0) { return 'No relevant memories found'; } // Update access_count for returned memories const ids = memories.map(m => m.id); await execute( `UPDATE memories SET access_count = access_count + 1, last_accessed_at = NOW() WHERE id = ANY($1)`, [ids] ); const lines = ['Relevant memories:\n']; for (const m of memories) { const sim = Math.round(m.similarity * 100); const proj = m.project ? ` [${m.project}]` : ''; lines.push(`**[${m.category}]${proj}** ${m.title} (${sim}% match)`); lines.push(` ${m.content}`); if (m.context) { lines.push(` _Context: ${m.context}_`); } lines.push(''); } return lines.join('\n'); } /** * List memories (non-semantic) */ export async function memoryList(args: MemoryListArgs): Promise { const { project, category, limit = 20 } = args; let whereClause = 'WHERE 1=1'; const params: unknown[] = []; let paramIndex = 1; if (project) { whereClause += ` AND (project = $${paramIndex++} OR project IS NULL)`; params.push(project); } if (category) { whereClause += ` AND category = $${paramIndex++}`; params.push(category); } params.push(limit); const memories = await query( `SELECT id, category, title, content, context, project, access_count, to_char(created_at, 'YYYY-MM-DD') as created_at FROM memories ${whereClause} ORDER BY created_at DESC LIMIT $${paramIndex}`, params ); if (memories.length === 0) { return `No memories found${project ? ` for project ${project}` : ''}`; } const lines = [`Memories${project ? ` (${project})` : ''}:\n`]; for (const m of memories) { const proj = m.project ? `[${m.project}] ` : ''; const accessed = m.access_count > 0 ? ` (accessed ${m.access_count}x)` : ''; lines.push(`• [${m.category}] ${proj}${m.title}${accessed}`); lines.push(` ${m.content.slice(0, 100)}${m.content.length > 100 ? '...' : ''}`); } return lines.join('\n'); } /** * Delete a memory by ID */ export async function memoryDelete(id: number): Promise { const result = await execute('DELETE FROM memories WHERE id = $1', [id]); if (result === 0) { return `Memory not found: ${id}`; } return `Deleted memory: ${id}`; } /** * Get memories relevant to current context (for session start) */ export async function memoryContext(project: string | null, taskDescription?: string): Promise { const lines: string[] = []; // Get project-specific memories if (project) { const projectMemories = await query( `SELECT category, title, content FROM memories WHERE project = $1 ORDER BY access_count DESC, created_at DESC LIMIT 5`, [project] ); if (projectMemories.length > 0) { lines.push(`**${project} Memories:**`); for (const m of projectMemories) { lines.push(`• [${m.category}] ${m.title}: ${m.content}`); } lines.push(''); } } // If task description provided, do semantic search if (taskDescription) { const embedding = await getEmbedding(taskDescription); if (embedding) { const relevant = await query( `SELECT category, title, content, project FROM memories WHERE embedding IS NOT NULL ORDER BY embedding <=> $1 LIMIT 3`, [formatEmbedding(embedding)] ); if (relevant.length > 0) { lines.push('**Relevant memories for this task:**'); for (const m of relevant) { const proj = m.project ? `[${m.project}] ` : ''; lines.push(`• ${proj}${m.title}: ${m.content}`); } } } } // Get recent gotchas (always useful) const gotchas = await query( `SELECT title, content FROM memories WHERE category = 'gotcha' ORDER BY created_at DESC LIMIT 3`, [] ); if (gotchas.length > 0) { lines.push('\n**Recent gotchas:**'); for (const g of gotchas) { lines.push(`⚠️ ${g.title}: ${g.content}`); } } return lines.length > 0 ? lines.join('\n') : 'No memories to surface'; }