import { getDatabase, Message, MessageInput } from './database.js'; import { randomUUID } from 'crypto'; export interface ConversationTree { id: string; title: string; task_description: string; children: ConversationTree[]; } export interface SearchResult { conversation_id: string; message_id: string; prev_message_id: string | null; next_message_id: string | null; author: string; payload: string; created_at: number; } /** * Build PostgreSQL text search query from keywords * Note: We pass the joined query to plainto_tsquery which handles sanitization * of special characters. The keywords themselves are not directly interpolated * into SQL - they're passed as parameterized values to avoid SQL injection. */ function buildTextSearchQuery(keywords: string[]): string { const trimmed = keywords.map(kw => kw.trim()).filter(k => k.length > 0); if (trimmed.length === 0) { return ''; } // Join keywords with space for plainto_tsquery to process as plain text // plainto_tsquery will handle any special characters safely return trimmed.join(' '); } /** * Save or update a conversation with messages */ export async function remixConversation( newConversationId: string, title: string, taskDescription: string, messages: MessageInput[], oldConversationId?: string ): Promise<{ success: boolean; conversation_id: string; message: string }> { try { // Validate inputs if (!newConversationId || typeof newConversationId !== 'string' || newConversationId.trim().length === 0) { return { success: false, conversation_id: newConversationId || '', message: 'new_conversation_id must be a non-empty string' }; } if (!title || typeof title !== 'string' || title.trim().length === 0) { return { success: false, conversation_id: newConversationId, message: 'title must be a non-empty string' }; } if (!taskDescription || typeof taskDescription !== 'string' || taskDescription.trim().length === 0) { return { success: false, conversation_id: newConversationId, message: 'task_description must be a non-empty string' }; } if (!Array.isArray(messages) || messages.length === 0) { return { success: false, conversation_id: newConversationId, message: 'messages must be a non-empty array' }; } // Validate each message for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (!msg.author || (msg.author !== 'User' && msg.author !== 'Copilot')) { return { success: false, conversation_id: newConversationId, message: `Message ${i + 1}: author must be either 'User' or 'Copilot'` }; } if (!msg.payload || typeof msg.payload !== 'string' || msg.payload.trim().length === 0) { return { success: false, conversation_id: newConversationId, message: `Message ${i + 1}: payload must be a non-empty string` }; } } const db = getDatabase(); // Validate oldConversationId if provided if (oldConversationId) { const parentResult = await db.query('SELECT id FROM conversations WHERE id = $1', [oldConversationId]); if (parentResult.rows.length === 0) { return { success: false, conversation_id: newConversationId, message: `Parent conversation '${oldConversationId}' does not exist` }; } } const now = Date.now(); // Begin transaction const client = await db.connect(); try { await client.query('BEGIN'); // Check if conversation already exists to preserve created_at and parent_id const existingConvResult = await client.query( 'SELECT created_at, parent_id FROM conversations WHERE id = $1', [newConversationId] ); const existingConv = existingConvResult.rows[0] as { created_at: number; parent_id: string | null } | undefined; const createdAt = existingConv ? existingConv.created_at : now; // When updating an existing conversation, preserve the existing parent_id if oldConversationId is not provided const finalParentId = oldConversationId !== undefined ? (oldConversationId || null) : (existingConv?.parent_id || null); // Upsert conversation await client.query(` INSERT INTO conversations (id, parent_id, title, task_description, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, task_description = EXCLUDED.task_description `, [newConversationId, finalParentId, title, taskDescription, createdAt]); // Delete existing messages for this conversation (in case of update) await client.query('DELETE FROM messages WHERE conversation_id = $1', [newConversationId]); // Insert messages with linked list structure let prevMessageId: string | null = null; for (let index = 0; index < messages.length; index++) { const msg = messages[index]; const messageId = randomUUID(); // Use incrementing timestamps to maintain relative ordering const messageCreatedAt = now + index; await client.query(` INSERT INTO messages (id, conversation_id, prev_message_id, next_message_id, author, payload, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) `, [messageId, newConversationId, prevMessageId, null, msg.author, msg.payload, messageCreatedAt]); // Update previous message's next_message_id if (prevMessageId) { await client.query( 'UPDATE messages SET next_message_id = $1 WHERE id = $2', [messageId, prevMessageId] ); } prevMessageId = messageId; } await client.query('COMMIT'); return { success: true, conversation_id: newConversationId, message: `Successfully saved conversation '${title}' with ${messages.length} messages` }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } catch (error) { return { success: false, conversation_id: newConversationId, message: `Error saving conversation: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Retrieve all messages for a conversation as an array */ export async function continueConversation(conversationId: string): Promise { try { const db = getDatabase(); // Check if conversation exists const conversationResult = await db.query('SELECT * FROM conversations WHERE id = $1', [conversationId]); if (conversationResult.rows.length === 0) { return null; } // Get all messages ordered by their linked list structure const messagesResult = await db.query(` SELECT id, conversation_id, prev_message_id, next_message_id, author, payload, created_at FROM messages WHERE conversation_id = $1 ORDER BY created_at ASC `, [conversationId]); return messagesResult.rows as Message[]; } catch (error) { console.error('Error retrieving conversation:', error); return null; } } /** * Build a tree structure of conversations */ export async function readConversationTree(rootConversation?: string): Promise { try { const db = getDatabase(); const MAX_DEPTH = 100; // Prevent stack overflow on deeply nested trees // Helper function to build tree recursively async function buildTree(parentId: string | null, depth: number = 0): Promise { if (depth > MAX_DEPTH) { console.warn(`Maximum tree depth (${MAX_DEPTH}) exceeded, stopping recursion`); return []; } const result = await db.query(` SELECT id, title, task_description FROM conversations WHERE parent_id ${parentId ? '= $1' : 'IS NULL'} ORDER BY created_at ASC `, parentId ? [parentId] : []); const conversations = result.rows as Array<{ id: string; title: string; task_description: string }>; const trees: ConversationTree[] = []; for (const conv of conversations) { trees.push({ id: conv.id, title: conv.title, task_description: conv.task_description, children: await buildTree(conv.id, depth + 1) }); } return trees; } if (rootConversation) { // Check if root exists const rootResult = await db.query( 'SELECT id, title, task_description FROM conversations WHERE id = $1', [rootConversation] ); const root = rootResult.rows[0] as { id: string; title: string; task_description: string } | undefined; if (!root) { return []; } return [{ id: root.id, title: root.title, task_description: root.task_description, children: await buildTree(root.id) }]; } else { // Return all root conversations (those without parents) return await buildTree(null); } } catch (error) { console.error('Error reading conversation tree:', error); return []; } } /** * Search messages from child conversation up to all parent conversations */ export async function searchParents(keywords: string[], childConversationId: string): Promise { try { const db = getDatabase(); // Validate inputs if (!Array.isArray(keywords) || keywords.length === 0) { console.error('keywords must be a non-empty array'); return []; } if (!childConversationId || typeof childConversationId !== 'string' || childConversationId.trim().length === 0) { console.error('child_conversation_id must be a non-empty string'); return []; } // Get all parent conversation IDs (walk up the tree) const conversationIds: string[] = [childConversationId]; const visited = new Set([childConversationId]); // Prevent cycles let currentId: string | null = childConversationId; const MAX_ITERATIONS = 100; // Prevent infinite loops let iterations = 0; while (currentId && iterations < MAX_ITERATIONS) { const parentResult = await db.query( 'SELECT parent_id FROM conversations WHERE id = $1', [currentId] ); const parent = parentResult.rows[0] as { parent_id: string | null } | undefined; if (parent?.parent_id) { if (visited.has(parent.parent_id)) { console.warn('Cycle detected in conversation tree, stopping traversal'); break; } conversationIds.push(parent.parent_id); visited.add(parent.parent_id); currentId = parent.parent_id; } else { currentId = null; } iterations++; } if (iterations >= MAX_ITERATIONS) { console.warn(`Maximum iterations (${MAX_ITERATIONS}) reached while traversing parent conversations`); } // Build PostgreSQL text search query const tsQuery = buildTextSearchQuery(keywords); if (tsQuery.length === 0) { return []; } // Search messages in all parent conversations using PostgreSQL full-text search // Use plainto_tsquery for plain text keyword search // Note: The placeholders variable contains a string like "$1,$2,$3" which is safe // because it's constructed from array indices (numbers), not user input. // The actual conversation IDs are passed as parameterized values in the array below. const placeholders = conversationIds.map((_, i) => `$${i + 1}`).join(','); const result = await db.query(` SELECT DISTINCT m.conversation_id, m.id as message_id, m.prev_message_id, m.next_message_id, m.author, m.payload, m.created_at FROM messages m WHERE m.conversation_id IN (${placeholders}) AND to_tsvector('english', m.payload) @@ plainto_tsquery('english', $${conversationIds.length + 1}) ORDER BY m.created_at DESC `, [...conversationIds, tsQuery]); return result.rows as SearchResult[]; } catch (error) { console.error('Error searching parents:', error); return []; } } /** * Search messages from parent conversation down to all child conversations recursively */ export async function searchChildren(keywords: string[], parentConversationId: string): Promise { try { const db = getDatabase(); // Validate inputs if (!Array.isArray(keywords) || keywords.length === 0) { console.error('keywords must be a non-empty array'); return []; } if (!parentConversationId || typeof parentConversationId !== 'string' || parentConversationId.trim().length === 0) { console.error('parent_conversation_id must be a non-empty string'); return []; } // Get all child conversation IDs (walk down the tree recursively) const conversationIds: string[] = [parentConversationId]; const visited = new Set([parentConversationId]); // Prevent cycles const MAX_DEPTH = 100; // Prevent stack overflow async function getChildren(parentId: string, depth: number = 0): Promise { if (depth > MAX_DEPTH) { console.warn(`Maximum depth (${MAX_DEPTH}) reached while traversing child conversations`); return; } const childrenResult = await db.query( 'SELECT id FROM conversations WHERE parent_id = $1', [parentId] ); const children = childrenResult.rows as Array<{ id: string }>; for (const child of children) { if (visited.has(child.id)) { console.warn('Cycle detected in conversation tree, skipping node'); continue; } conversationIds.push(child.id); visited.add(child.id); await getChildren(child.id, depth + 1); // Recursive call } } await getChildren(parentConversationId); // Build PostgreSQL text search query const tsQuery = buildTextSearchQuery(keywords); if (tsQuery.length === 0) { return []; } // Search messages in all child conversations using PostgreSQL full-text search // Use plainto_tsquery for plain text keyword search // Note: The placeholders variable contains a string like "$1,$2,$3" which is safe // because it's constructed from array indices (numbers), not user input. // The actual conversation IDs are passed as parameterized values in the array below. const placeholders = conversationIds.map((_, i) => `$${i + 1}`).join(','); const result = await db.query(` SELECT DISTINCT m.conversation_id, m.id as message_id, m.prev_message_id, m.next_message_id, m.author, m.payload, m.created_at FROM messages m WHERE m.conversation_id IN (${placeholders}) AND to_tsvector('english', m.payload) @@ plainto_tsquery('english', $${conversationIds.length + 1}) ORDER BY m.created_at DESC `, [...conversationIds, tsQuery]); return result.rows as SearchResult[]; } catch (error) { console.error('Error searching children:', error); return []; } }