From 73be8f7a632e911562ab85ca8d92431b8bd09fdc Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Wed, 10 Dec 2025 18:04:49 -0500 Subject: [PATCH] Fix export/import feature: JSON format and project filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two critical fixes for the memory export/import feature: 1. **SearchManager empty results bug**: The /api/search endpoint with format=json now returns {observations: [], sessions: [], prompts: []} when no results are found, instead of the MCP protocol format. This fixes the export-memories script which expects consistent JSON structure regardless of result count. 2. **Project filtering support**: Updated SessionStore methods (getObservationsByIds, getSessionSummariesByIds, getUserPromptsByIds) to accept and apply project filter parameter. This enables proper project-based filtering during ChromaDB hybrid search result hydration. Testing: - Export with results: ✅ 50 observations exported - Export with empty results: ✅ Proper JSON structure - Round-trip import: ✅ Duplicate prevention working - Project filtering: ✅ claude-mem (51 obs) vs rad-mem (1 obs) Fixes export/import feature blocking bugs. --- src/services/sqlite/SessionStore.ts | 83 ++++++++++++++++++++++++---- src/services/worker/SearchManager.ts | 16 ++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/src/services/sqlite/SessionStore.ts b/src/services/sqlite/SessionStore.ts index b46d017c..a9ac80a7 100644 --- a/src/services/sqlite/SessionStore.ts +++ b/src/services/sqlite/SessionStore.ts @@ -811,26 +811,73 @@ export class SessionStore { */ getObservationsByIds( ids: number[], - options: { orderBy?: 'date_desc' | 'date_asc'; limit?: number } = {} + options: { orderBy?: 'date_desc' | 'date_asc'; limit?: number; project?: string; type?: string | string[]; concepts?: string | string[]; files?: string | string[] } = {} ): ObservationRecord[] { if (ids.length === 0) return []; - const { orderBy = 'date_desc', limit } = options; + const { orderBy = 'date_desc', limit, project, type, concepts, files } = options; const orderClause = orderBy === 'date_asc' ? 'ASC' : 'DESC'; const limitClause = limit ? `LIMIT ${limit}` : ''; // Build placeholders for IN clause const placeholders = ids.map(() => '?').join(','); + const params: any[] = [...ids]; + const additionalConditions: string[] = []; + + // Apply project filter + if (project) { + additionalConditions.push('project = ?'); + params.push(project); + } + + // Apply type filter + if (type) { + if (Array.isArray(type)) { + const typePlaceholders = type.map(() => '?').join(','); + additionalConditions.push(`type IN (${typePlaceholders})`); + params.push(...type); + } else { + additionalConditions.push('type = ?'); + params.push(type); + } + } + + // Apply concepts filter + if (concepts) { + const conceptsList = Array.isArray(concepts) ? concepts : [concepts]; + const conceptConditions = conceptsList.map(() => { + params.push(); + return 'EXISTS (SELECT 1 FROM json_each(concepts) WHERE value = ?)'; + }); + params.push(...conceptsList); + additionalConditions.push(`(${conceptConditions.join(' OR ')})`); + } + + // Apply files filter + if (files) { + const filesList = Array.isArray(files) ? files : [files]; + const fileConditions = filesList.map(() => { + return '(EXISTS (SELECT 1 FROM json_each(files_read) WHERE value LIKE ?) OR EXISTS (SELECT 1 FROM json_each(files_modified) WHERE value LIKE ?))'; + }); + filesList.forEach(file => { + params.push(`%${file}%`, `%${file}%`); + }); + additionalConditions.push(`(${fileConditions.join(' OR ')})`); + } + + const whereClause = additionalConditions.length > 0 + ? `WHERE id IN (${placeholders}) AND ${additionalConditions.join(' AND ')}` + : `WHERE id IN (${placeholders})`; const stmt = this.db.prepare(` SELECT * FROM observations - WHERE id IN (${placeholders}) + ${whereClause} ORDER BY created_at_epoch ${orderClause} ${limitClause} `); - return stmt.all(...ids) as ObservationRecord[]; + return stmt.all(...params) as ObservationRecord[]; } /** @@ -1353,23 +1400,30 @@ export class SessionStore { */ getSessionSummariesByIds( ids: number[], - options: { orderBy?: 'date_desc' | 'date_asc'; limit?: number } = {} + options: { orderBy?: 'date_desc' | 'date_asc'; limit?: number; project?: string } = {} ): SessionSummaryRecord[] { if (ids.length === 0) return []; - const { orderBy = 'date_desc', limit } = options; + const { orderBy = 'date_desc', limit, project } = options; const orderClause = orderBy === 'date_asc' ? 'ASC' : 'DESC'; const limitClause = limit ? `LIMIT ${limit}` : ''; const placeholders = ids.map(() => '?').join(','); + const params: any[] = [...ids]; + + // Apply project filter + const whereClause = project + ? `WHERE id IN (${placeholders}) AND project = ?` + : `WHERE id IN (${placeholders})`; + if (project) params.push(project); const stmt = this.db.prepare(` SELECT * FROM session_summaries - WHERE id IN (${placeholders}) + ${whereClause} ORDER BY created_at_epoch ${orderClause} ${limitClause} `); - return stmt.all(...ids) as SessionSummaryRecord[]; + return stmt.all(...params) as SessionSummaryRecord[]; } /** @@ -1378,14 +1432,19 @@ export class SessionStore { */ getUserPromptsByIds( ids: number[], - options: { orderBy?: 'date_desc' | 'date_asc'; limit?: number } = {} + options: { orderBy?: 'date_desc' | 'date_asc'; limit?: number; project?: string } = {} ): UserPromptRecord[] { if (ids.length === 0) return []; - const { orderBy = 'date_desc', limit } = options; + const { orderBy = 'date_desc', limit, project } = options; const orderClause = orderBy === 'date_asc' ? 'ASC' : 'DESC'; const limitClause = limit ? `LIMIT ${limit}` : ''; const placeholders = ids.map(() => '?').join(','); + const params: any[] = [...ids]; + + // Apply project filter + const projectFilter = project ? 'AND s.project = ?' : ''; + if (project) params.push(project); const stmt = this.db.prepare(` SELECT @@ -1394,12 +1453,12 @@ export class SessionStore { s.sdk_session_id FROM user_prompts up JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id - WHERE up.id IN (${placeholders}) + WHERE up.id IN (${placeholders}) ${projectFilter} ORDER BY up.created_at_epoch ${orderClause} ${limitClause} `); - return stmt.all(...ids) as UserPromptRecord[]; + return stmt.all(...params) as UserPromptRecord[]; } /** diff --git a/src/services/worker/SearchManager.ts b/src/services/worker/SearchManager.ts index 95074283..df5b28ab 100644 --- a/src/services/worker/SearchManager.ts +++ b/src/services/worker/SearchManager.ts @@ -198,6 +198,13 @@ export class SearchManager { const totalResults = observations.length + sessions.length + prompts.length; if (totalResults === 0) { + if (format === 'json') { + return { + observations: [], + sessions: [], + prompts: [] + }; + } return { content: [{ type: 'text' as const, @@ -230,6 +237,15 @@ export class SearchManager { const limitedResults = allResults.slice(0, options.limit || 20); // Format based on requested format + if (format === 'json') { + // Raw JSON format for exports + return { + observations, + sessions, + prompts + }; + } + let combinedText: string; if (format === 'index') { const header = `Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts):\n\n`;