From 50ee3c7548ce4097eac0378dd279df32e148aaf5 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:39:48 +0300 Subject: [PATCH] refactor: move search to module --- server/index.js | 47 - server/modules/providers/provider.routes.ts | 108 ++- .../session-conversations-search.service.ts | 918 ++++++++++++++++++ server/projects.js | 718 +------------- .../sidebar/hooks/useSidebarController.ts | 2 +- src/utils/api.js | 2 +- 6 files changed, 1017 insertions(+), 778 deletions(-) create mode 100644 server/modules/providers/services/session-conversations-search.service.ts diff --git a/server/index.js b/server/index.js index 60f14311..702abbf1 100755 --- a/server/index.js +++ b/server/index.js @@ -21,7 +21,6 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; import { deleteSessionById, getProjectPathById, - searchConversations, } from './projects.js'; import { queryClaudeSDK, @@ -334,52 +333,6 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) = } }); -// Delete project endpoint -// Search conversations content (SSE streaming) -app.get('/api/search/conversations', authenticateToken, async (req, res) => { - const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''; - const parsedLimit = Number.parseInt(String(req.query.limit), 10); - const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100)); - - if (query.length < 2) { - return res.status(400).json({ error: 'Query must be at least 2 characters' }); - } - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - - let closed = false; - const abortController = new AbortController(); - req.on('close', () => { closed = true; abortController.abort(); }); - - try { - await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => { - if (closed) return; - if (projectResult) { - res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`); - } else { - res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`); - } - }, abortController.signal); - if (!closed) { - res.write(`event: done\ndata: {}\n\n`); - } - } catch (error) { - console.error('Error searching conversations:', error); - if (!closed) { - res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`); - } - } finally { - if (!closed) { - res.end(); - } - } -}); - const expandWorkspacePath = (inputPath) => { if (!inputPath) return inputPath; if (inputPath === '~') { diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 97cdd242..dfc9247e 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; +import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js'; import { sessionsService } from '@/modules/providers/services/sessions.service.js'; import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js'; import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js'; @@ -141,19 +142,19 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput = args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined, env: typeof body.env === 'object' && body.env !== null ? Object.fromEntries( - Object.entries(body.env as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string', - ), - ) + Object.entries(body.env as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) : undefined, cwd: readOptionalQueryString(body.cwd), url: readOptionalQueryString(body.url), headers: typeof body.headers === 'object' && body.headers !== null ? Object.fromEntries( - Object.entries(body.headers as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string', - ), - ) + Object.entries(body.headers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) : undefined, envVars: Array.isArray(body.envVars) ? body.envVars.filter((entry): entry is string => typeof entry === 'string') @@ -161,10 +162,10 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput = bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar), envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null ? Object.fromEntries( - Object.entries(body.envHttpHeaders as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string', - ), - ) + Object.entries(body.envHttpHeaders as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) : undefined, }; }; @@ -208,6 +209,35 @@ const parseSessionRenameSummary = (payload: unknown): string => { return summary; }; +const parseSessionSearchQuery = (value: unknown): string => { + const query = readOptionalQueryString(value) ?? ''; + if (query.length < 2) { + throw new AppError('Query must be at least 2 characters', { + code: 'INVALID_SEARCH_QUERY', + statusCode: 400, + }); + } + + return query; +}; + +const parseSessionSearchLimit = (value: unknown): number => { + const raw = readOptionalQueryString(value); + if (!raw) { + return 50; + } + + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) { + throw new AppError('limit must be a valid integer.', { + code: 'INVALID_QUERY_PARAMETER', + statusCode: 400, + }); + } + + return Math.max(1, Math.min(parsed, 100)); +}; + router.get( '/:provider/auth/status', asyncHandler(async (req: Request, res: Response) => { @@ -217,6 +247,7 @@ router.get( }), ); +// ----------------- MCP routes ----------------- router.get( '/:provider/mcp/servers', asyncHandler(async (req: Request, res: Response) => { @@ -279,6 +310,7 @@ router.post( }), ); +// ----------------- Session routes ----------------- router.delete( '/sessions/:sessionId', asyncHandler(async (req: Request, res: Response) => { @@ -331,4 +363,56 @@ router.get( }), ); +router.get('/search/sessions', asyncHandler(async (req: Request, res: Response) => { + const query = parseSessionSearchQuery(req.query.q); + const limit = parseSessionSearchLimit(req.query.limit); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + let closed = false; + const abortController = new AbortController(); + req.on('close', () => { + closed = true; + abortController.abort(); + }); + + try { + await sessionConversationsSearchService.search({ + query, + limit, + signal: abortController.signal, + onProgress: ({ projectResult, totalMatches, scannedProjects, totalProjects }) => { + if (closed) { + return; + } + + if (projectResult) { + res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`); + return; + } + + res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`); + }, + }); + + if (!closed) { + res.write('event: done\ndata: {}\n\n'); + } + } catch (error) { + console.error('Error searching conversations:', error); + if (!closed) { + res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`); + } + } finally { + if (!closed) { + res.end(); + } + } +})); + export default router; diff --git a/server/modules/providers/services/session-conversations-search.service.ts b/server/modules/providers/services/session-conversations-search.service.ts new file mode 100644 index 00000000..58b2bbe9 --- /dev/null +++ b/server/modules/providers/services/session-conversations-search.service.ts @@ -0,0 +1,918 @@ +import fsSync, { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; + +import { projectsDb } from '@/modules/database/index.js'; +import { generateDisplayName } from '@/modules/projects/index.js'; +import sessionManager from '@/sessionManager.js'; + +type AnyRecord = Record; + +type SearchSnippetHighlight = { + start: number; + end: number; +}; + +type SessionConversationMatch = { + role: string; + snippet: string; + highlights: SearchSnippetHighlight[]; + timestamp: string | null; + provider: 'claude' | 'codex' | 'gemini'; + messageUuid?: string | null; +}; + +type SessionConversationResult = { + sessionId: string; + provider: 'claude' | 'codex' | 'gemini'; + sessionSummary: string; + matches: SessionConversationMatch[]; +}; + +type ProjectConversationResult = { + projectId: string | null; + projectName: string; + projectDisplayName: string; + sessions: SessionConversationResult[]; +}; + +export type SessionConversationSearchProgressUpdate = { + projectResult: ProjectConversationResult | null; + totalMatches: number; + scannedProjects: number; + totalProjects: number; +}; + +type SearchSessionConversationsInput = { + query: string; + limit: number; + signal?: AbortSignal; + onProgress?: (update: SessionConversationSearchProgressUpdate) => void; +}; + +const projectDirectoryCache = new Map(); + +async function loadProjectConfig(): Promise> { + const configPath = path.join(os.homedir(), '.claude', 'project-config.json'); + try { + const configData = await fs.readFile(configPath, 'utf8'); + return JSON.parse(configData) as Record; + } catch { + return {}; + } +} + +async function extractProjectDirectory(projectName: string): Promise { + if (projectDirectoryCache.has(projectName)) { + return projectDirectoryCache.get(projectName) as string; + } + + const config = await loadProjectConfig(); + if (config[projectName]?.originalPath) { + const originalPath = String(config[projectName].originalPath); + projectDirectoryCache.set(projectName, originalPath); + return originalPath; + } + + const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); + const cwdCounts = new Map(); + let latestTimestamp = 0; + let latestCwd: string | null = null; + let extractedPath: string; + + try { + await fs.access(projectDir); + + const files = await fs.readdir(projectDir); + const jsonlFiles = files.filter((file) => file.endsWith('.jsonl')); + + if (jsonlFiles.length === 0) { + extractedPath = projectName.replace(/-/g, '/'); + } else { + for (const file of jsonlFiles) { + const jsonlFile = path.join(projectDir, file); + const fileStream = fsSync.createReadStream(jsonlFile); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + for await (const line of rl) { + if (!line.trim()) { + continue; + } + + try { + const entry = JSON.parse(line) as AnyRecord; + if (!entry.cwd) { + continue; + } + + const cwd = String(entry.cwd); + cwdCounts.set(cwd, (cwdCounts.get(cwd) || 0) + 1); + + const timestamp = new Date(entry.timestamp || 0).getTime(); + if (timestamp > latestTimestamp) { + latestTimestamp = timestamp; + latestCwd = cwd; + } + } catch { + // Skip malformed lines. + } + } + } + + if (cwdCounts.size === 0) { + extractedPath = projectName.replace(/-/g, '/'); + } else if (cwdCounts.size === 1) { + extractedPath = Array.from(cwdCounts.keys())[0] as string; + } else { + const latestCount = latestCwd ? (cwdCounts.get(latestCwd) || 0) : 0; + const maxCount = Math.max(...cwdCounts.values()); + + if (latestCount >= maxCount * 0.25 && latestCwd) { + extractedPath = latestCwd; + } else { + let mostFrequentPath = ''; + for (const [cwd, count] of cwdCounts.entries()) { + if (count === maxCount) { + mostFrequentPath = cwd; + break; + } + } + + extractedPath = mostFrequentPath || latestCwd || projectName.replace(/-/g, '/'); + } + } + } + + projectDirectoryCache.set(projectName, extractedPath); + return extractedPath; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + extractedPath = projectName.replace(/-/g, '/'); + } else { + console.error(`Error extracting project directory for ${projectName}:`, error); + extractedPath = projectName.replace(/-/g, '/'); + } + + projectDirectoryCache.set(projectName, extractedPath); + return extractedPath; + } +} + +function normalizeComparablePath(inputPath: string): string { + if (!inputPath || typeof inputPath !== 'string') { + return ''; + } + + const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\') + ? inputPath.slice(4) + : inputPath; + const normalized = path.normalize(withoutLongPathPrefix.trim()); + if (!normalized) { + return ''; + } + + const resolved = path.resolve(normalized); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +async function findCodexJsonlFiles(dir: string): Promise { + const files: string[] = []; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await findCodexJsonlFiles(fullPath)); + } else if (entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + } catch { + // Skip directories we can't read. + } + + return files; +} + +async function searchCodexSessionsForProject( + projectPath: string, + projectResult: ProjectConversationResult, + allWordsMatch: (textLower: string) => boolean, + buildSnippet: (text: string, textLower: string) => { snippet: string; highlights: SearchSnippetHighlight[] }, + limit: number, + getTotalMatches: () => number, + addMatches: (count: number) => void, + isAborted: () => boolean, +): Promise { + const normalizedProjectPath = normalizeComparablePath(projectPath); + if (!normalizedProjectPath) { + return; + } + + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + try { + await fs.access(codexSessionsDir); + } catch { + return; + } + + const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); + + for (const filePath of jsonlFiles) { + if (getTotalMatches() >= limit || isAborted()) { + break; + } + + try { + const fileStream = fsSync.createReadStream(filePath); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + let sessionMeta: AnyRecord | null = null; + for await (const line of rl) { + if (!line.trim()) { + continue; + } + + try { + const entry = JSON.parse(line) as AnyRecord; + if (entry.type === 'session_meta' && entry.payload) { + sessionMeta = entry.payload as AnyRecord; + break; + } + } catch { + // Skip malformed lines. + } + } + + if (!sessionMeta) { + continue; + } + + const sessionProjectPath = normalizeComparablePath(String(sessionMeta.cwd || '')); + if (sessionProjectPath !== normalizedProjectPath) { + continue; + } + + const fileStream2 = fsSync.createReadStream(filePath); + const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity }); + let latestUserMessageText: string | null = null; + const matches: SessionConversationMatch[] = []; + + for await (const line of rl2) { + if (getTotalMatches() >= limit || isAborted()) { + break; + } + if (!line.trim()) { + continue; + } + + let entry: AnyRecord; + try { + entry = JSON.parse(line) as AnyRecord; + } catch { + continue; + } + + let text: string | null = null; + let role: string | null = null; + + if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) { + text = String(entry.payload.message); + role = 'user'; + latestUserMessageText = text; + } else if (entry.type === 'response_item' && entry.payload?.type === 'message') { + const contentParts = Array.isArray(entry.payload.content) ? entry.payload.content : []; + if (entry.payload.role === 'user') { + text = contentParts + .filter((part: AnyRecord) => part.type === 'input_text' && part.text) + .map((part: AnyRecord) => String(part.text)) + .join(' '); + role = 'user'; + if (text) { + latestUserMessageText = text; + } + } else if (entry.payload.role === 'assistant') { + text = contentParts + .filter((part: AnyRecord) => part.type === 'output_text' && part.text) + .map((part: AnyRecord) => String(part.text)) + .join(' '); + role = 'assistant'; + } + } + + if (!text || !role) { + continue; + } + + const textLower = text.toLowerCase(); + if (!allWordsMatch(textLower)) { + continue; + } + + if (matches.length < 2) { + const { snippet, highlights } = buildSnippet(text, textLower); + matches.push({ + role, + snippet, + highlights, + timestamp: entry.timestamp ? String(entry.timestamp) : null, + provider: 'codex', + }); + addMatches(1); + } + } + + if (matches.length > 0) { + projectResult.sessions.push({ + sessionId: String(sessionMeta.id || ''), + provider: 'codex', + sessionSummary: latestUserMessageText + ? (latestUserMessageText.length > 50 ? `${latestUserMessageText.substring(0, 50)}...` : latestUserMessageText) + : 'Codex Session', + matches, + }); + } + } catch { + // Skip unreadable or malformed files. + } + } +} + +async function searchGeminiSessionsForProject( + projectPath: string, + projectResult: ProjectConversationResult, + allWordsMatch: (textLower: string) => boolean, + buildSnippet: (text: string, textLower: string) => { snippet: string; highlights: SearchSnippetHighlight[] }, + limit: number, + getTotalMatches: () => number, + addMatches: (count: number) => void, +): Promise { + for (const [sessionId, session] of sessionManager.sessions as Map) { + if (getTotalMatches() >= limit) { + break; + } + if (session.projectPath !== projectPath) { + continue; + } + + const matches: SessionConversationMatch[] = []; + const sourceMessages = Array.isArray(session.messages) ? session.messages : []; + + for (const msg of sourceMessages) { + if (getTotalMatches() >= limit) { + break; + } + if (msg.role !== 'user' && msg.role !== 'assistant') { + continue; + } + + const text = typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content.filter((part: AnyRecord) => part.type === 'text').map((part: AnyRecord) => String(part.text)).join(' ') + : ''; + if (!text) { + continue; + } + + const textLower = text.toLowerCase(); + if (!allWordsMatch(textLower)) { + continue; + } + + if (matches.length < 2) { + const { snippet, highlights } = buildSnippet(text, textLower); + matches.push({ + role: String(msg.role), + snippet, + highlights, + timestamp: msg.timestamp ? new Date(msg.timestamp).toISOString() : null, + provider: 'gemini', + }); + addMatches(1); + } + } + + if (matches.length > 0) { + const firstUserMessage = sourceMessages.find((msg: AnyRecord) => msg.role === 'user'); + const summary = firstUserMessage?.content + ? (typeof firstUserMessage.content === 'string' + ? (firstUserMessage.content.length > 50 ? `${firstUserMessage.content.substring(0, 50)}...` : firstUserMessage.content) + : 'Gemini Session') + : 'Gemini Session'; + + projectResult.sessions.push({ + sessionId, + provider: 'gemini', + sessionSummary: summary, + matches, + }); + } + } + + const normalizedProjectPath = normalizeComparablePath(projectPath); + if (!normalizedProjectPath) { + return; + } + + const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); + try { + await fs.access(geminiTmpDir); + } catch { + return; + } + + const trackedSessionIds = new Set(); + for (const [sid] of sessionManager.sessions as Map) { + trackedSessionIds.add(String(sid)); + } + + let projectDirs: string[]; + try { + projectDirs = await fs.readdir(geminiTmpDir); + } catch { + return; + } + + for (const projectDir of projectDirs) { + if (getTotalMatches() >= limit) { + break; + } + + const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root'); + let projectRoot = ''; + try { + projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim(); + } catch { + continue; + } + + if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) { + continue; + } + + const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); + let chatFiles: string[]; + try { + chatFiles = await fs.readdir(chatsDir); + } catch { + continue; + } + + for (const chatFile of chatFiles) { + if (getTotalMatches() >= limit) { + break; + } + if (!chatFile.endsWith('.json')) { + continue; + } + + try { + const filePath = path.join(chatsDir, chatFile); + const data = await fs.readFile(filePath, 'utf8'); + const session = JSON.parse(data) as AnyRecord; + if (!session.messages || !Array.isArray(session.messages)) { + continue; + } + + const cliSessionId = String(session.sessionId || chatFile.replace('.json', '')); + if (trackedSessionIds.has(cliSessionId)) { + continue; + } + + const matches: SessionConversationMatch[] = []; + let firstUserText: string | null = null; + + for (const msg of session.messages as AnyRecord[]) { + if (getTotalMatches() >= limit) { + break; + } + + const role = msg.type === 'user' + ? 'user' + : (msg.type === 'gemini' || msg.type === 'assistant') + ? 'assistant' + : null; + if (!role) { + continue; + } + + let text = ''; + if (typeof msg.content === 'string') { + text = msg.content; + } else if (Array.isArray(msg.content)) { + text = msg.content + .filter((part: AnyRecord) => part.text) + .map((part: AnyRecord) => String(part.text)) + .join(' '); + } + + if (!text) { + continue; + } + if (role === 'user' && !firstUserText) { + firstUserText = text; + } + + const textLower = text.toLowerCase(); + if (!allWordsMatch(textLower)) { + continue; + } + + if (matches.length < 2) { + const { snippet, highlights } = buildSnippet(text, textLower); + matches.push({ + role, + snippet, + highlights, + timestamp: msg.timestamp ? String(msg.timestamp) : null, + provider: 'gemini', + }); + addMatches(1); + } + } + + if (matches.length > 0) { + const summary = firstUserText + ? (firstUserText.length > 50 ? `${firstUserText.substring(0, 50)}...` : firstUserText) + : 'Gemini CLI Session'; + + projectResult.sessions.push({ + sessionId: cliSessionId, + provider: 'gemini', + sessionSummary: summary, + matches, + }); + } + } catch { + // Skip unreadable or malformed files. + } + } + } +} + +export async function searchConversations( + query: string, + limit = 50, + onProjectResult: ((update: SessionConversationSearchProgressUpdate) => void) | null = null, + signal: AbortSignal | null = null, +): Promise<{ results: ProjectConversationResult[]; totalMatches: number; query: string }> { + const safeQuery = typeof query === 'string' ? query.trim() : ''; + const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200)); + const claudeDir = path.join(os.homedir(), '.claude', 'projects'); + const config = await loadProjectConfig(); + const results: ProjectConversationResult[] = []; + let totalMatches = 0; + const words = safeQuery.toLowerCase().split(/\s+/).filter((word) => word.length > 0); + if (words.length === 0) { + return { results: [], totalMatches: 0, query: safeQuery }; + } + + const isAborted = () => signal?.aborted === true; + + const isSystemMessage = (textContent: string): boolean => { + return typeof textContent === 'string' && ( + textContent.startsWith('') || + textContent.startsWith('') || + textContent.startsWith('') || + textContent.startsWith('') || + textContent.startsWith('') || + textContent.startsWith('Caveat:') || + textContent.startsWith('This session is being continued from a previous') || + textContent.startsWith('Invalid API key') || + textContent.includes('{"subtasks":') || + textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || + textContent === 'Warmup' + ); + }; + + const extractText = (content: unknown): string => { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return content + .filter((part: AnyRecord) => part.type === 'text' && part.text) + .map((part: AnyRecord) => String(part.text)) + .join(' '); + } + return ''; + }; + + const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const wordPatterns = words.map((word) => new RegExp(`(? wordPatterns.every((pattern) => pattern.test(textLower)); + + const buildSnippet = ( + text: string, + textLower: string, + snippetLen = 150, + ): { snippet: string; highlights: SearchSnippetHighlight[] } => { + let firstIndex = -1; + let firstWordLen = 0; + for (const word of words) { + const regex = new RegExp(`(? 0 ? '...' : ''; + const suffix = end < text.length ? '...' : ''; + const snippet = `${prefix}${text.slice(start, end).replace(/\n/g, ' ')}${suffix}`; + + const snippetLower = snippet.toLowerCase(); + const highlights: SearchSnippetHighlight[] = []; + for (const word of words) { + const regex = new RegExp(`(? left.start - right.start); + const merged: SearchSnippetHighlight[] = []; + for (const highlight of highlights) { + const previous = merged[merged.length - 1]; + if (previous && highlight.start <= previous.end) { + previous.end = Math.max(previous.end, highlight.end); + } else { + merged.push({ ...highlight }); + } + } + + return { snippet, highlights: merged }; + }; + + try { + await fs.access(claudeDir); + const entries = await fs.readdir(claudeDir, { withFileTypes: true }); + const projectDirs = entries.filter((entry) => entry.isDirectory()); + let scannedProjects = 0; + const totalProjects = projectDirs.length; + + for (const projectEntry of projectDirs) { + if (totalMatches >= safeLimit || isAborted()) { + break; + } + + const projectName = projectEntry.name; + const projectDir = path.join(claudeDir, projectName); + const projectDisplayName = config[projectName]?.displayName + ? String(config[projectName].displayName) + : await generateDisplayName(projectName); + + let files: string[]; + try { + files = await fs.readdir(projectDir); + } catch { + continue; + } + + const jsonlFiles = files.filter( + (file) => file.endsWith('.jsonl') && !file.startsWith('agent-'), + ); + + let searchProjectId: string | null = null; + try { + const resolvedPath = await extractProjectDirectory(projectName); + const dbRow = projectsDb.getProjectPath(resolvedPath); + if (dbRow?.project_id) { + searchProjectId = String(dbRow.project_id); + } + } catch { + // Best-effort project id resolution. + } + + const projectResult: ProjectConversationResult = { + projectId: searchProjectId, + projectName, + projectDisplayName, + sessions: [], + }; + + for (const file of jsonlFiles) { + if (totalMatches >= safeLimit || isAborted()) { + break; + } + + const filePath = path.join(projectDir, file); + const sessionMatches = new Map(); + const sessionSummaries = new Map(); + const pendingSummaries = new Map(); + const sessionLastMessages = new Map(); + let currentSessionId: string | null = null; + + try { + const fileStream = fsSync.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + for await (const line of rl) { + if (totalMatches >= safeLimit || isAborted()) { + break; + } + if (!line.trim()) { + continue; + } + + let entry: AnyRecord; + try { + entry = JSON.parse(line) as AnyRecord; + } catch { + continue; + } + + if (entry.sessionId) { + currentSessionId = String(entry.sessionId); + } + + if (entry.type === 'summary' && entry.summary) { + const summary = String(entry.summary); + const sid = entry.sessionId + ? String(entry.sessionId) + : currentSessionId; + if (sid) { + sessionSummaries.set(sid, summary); + } else if (entry.leafUuid) { + pendingSummaries.set(String(entry.leafUuid), summary); + } + } + + if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) { + const pendingSummary = pendingSummaries.get(String(entry.parentUuid)); + if (pendingSummary) { + sessionSummaries.set(currentSessionId, pendingSummary); + } + } + + if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) { + const role = entry.message.role; + if (role === 'user' || role === 'assistant') { + const text = extractText(entry.message.content); + if (text && !isSystemMessage(text)) { + if (!sessionLastMessages.has(currentSessionId)) { + sessionLastMessages.set(currentSessionId, {}); + } + + const messages = sessionLastMessages.get(currentSessionId) as { + user?: string; + assistant?: string; + }; + if (role === 'user') { + messages.user = text; + } else { + messages.assistant = text; + } + } + } + } + + if (!entry.message?.content) { + continue; + } + if (entry.message.role !== 'user' && entry.message.role !== 'assistant') { + continue; + } + if (entry.isApiErrorMessage) { + continue; + } + + const text = extractText(entry.message.content); + if (!text || isSystemMessage(text)) { + continue; + } + + const textLower = text.toLowerCase(); + if (!allWordsMatch(textLower)) { + continue; + } + + const resolvedSessionId = entry.sessionId + ? String(entry.sessionId) + : currentSessionId || file.replace('.jsonl', ''); + if (!sessionMatches.has(resolvedSessionId)) { + sessionMatches.set(resolvedSessionId, []); + } + + const matches = sessionMatches.get(resolvedSessionId) as SessionConversationMatch[]; + if (matches.length < 2) { + const { snippet, highlights } = buildSnippet(text, textLower); + matches.push({ + role: String(entry.message.role), + snippet, + highlights, + timestamp: entry.timestamp ? String(entry.timestamp) : null, + provider: 'claude', + messageUuid: entry.uuid ? String(entry.uuid) : null, + }); + totalMatches += 1; + } + } + } catch { + // Skip unreadable or malformed files. + } + + for (const [sessionId, matches] of sessionMatches.entries()) { + const lastMessages = sessionLastMessages.get(sessionId); + const fallback = lastMessages?.user || lastMessages?.assistant; + projectResult.sessions.push({ + sessionId, + provider: 'claude', + sessionSummary: sessionSummaries.get(sessionId) + || (fallback ? (fallback.length > 50 ? `${fallback.substring(0, 50)}...` : fallback) : 'New Session'), + matches, + }); + } + } + + try { + const actualProjectDir = await extractProjectDirectory(projectName); + if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { + await searchCodexSessionsForProject( + actualProjectDir, + projectResult, + allWordsMatch, + buildSnippet, + safeLimit, + () => totalMatches, + (count) => { totalMatches += count; }, + isAborted, + ); + } + } catch { + // Skip codex search errors. + } + + try { + const actualProjectDir = await extractProjectDirectory(projectName); + if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { + await searchGeminiSessionsForProject( + actualProjectDir, + projectResult, + allWordsMatch, + buildSnippet, + safeLimit, + () => totalMatches, + (count) => { totalMatches += count; }, + ); + } + } catch { + // Skip gemini search errors. + } + + scannedProjects += 1; + if (projectResult.sessions.length > 0) { + results.push(projectResult); + onProjectResult?.({ projectResult, totalMatches, scannedProjects, totalProjects }); + } else if (onProjectResult && scannedProjects % 10 === 0) { + onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects }); + } + } + } catch { + // ~/.claude/projects does not exist. + } + + return { results, totalMatches, query: safeQuery }; +} + +/** + * Application service for session-conversation search. + * + * Provider routes call this service so route handlers stay focused on + * request parsing/response formatting, while search execution remains + * centralized in one place. + */ +export const sessionConversationsSearchService = { + /** + * Streams progress updates while the search scans provider session logs. + */ + async search(input: SearchSessionConversationsInput): Promise { + await searchConversations( + input.query, + input.limit, + input.onProgress ?? null, + input.signal ?? null, + ); + }, +}; diff --git a/server/projects.js b/server/projects.js index 3cc0bbdc..9507206a 100755 --- a/server/projects.js +++ b/server/projects.js @@ -17,11 +17,8 @@ * features that still need on-disk data: * - Session message reads for each provider (Claude/Codex/Gemini) for * `GET /api/providers/sessions/:sessionId/messages`. - * - Conversation search (`searchConversations`) which scans JSONL history. * - (Project row removal / JSONL cleanup is handled in * `modules/projects/services/project-delete.service.ts`.) - * - Manual project registration (`addProjectManually`) which syncs to - * ~/.claude/project-config.json for backwards compatibility. */ import fsSync, { promises as fs } from 'fs'; @@ -29,9 +26,6 @@ import path from 'path'; import readline from 'readline'; import os from 'os'; -import { generateDisplayName } from '@/modules/projects'; - -import sessionManager from './sessionManager.js'; import { projectsDb } from './modules/database/index.js'; /** @@ -68,160 +62,6 @@ function claudeFolderNameFromPath(projectPath) { return projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); } -// Cache for extracted project directories -const projectDirectoryCache = new Map(); - -// Load project configuration file -async function loadProjectConfig() { - const configPath = path.join(os.homedir(), '.claude', 'project-config.json'); - try { - const configData = await fs.readFile(configPath, 'utf8'); - return JSON.parse(configData); - } catch (error) { - // Return empty config if file doesn't exist - return {}; - } -} - -// Save project configuration file -async function saveProjectConfig(config) { - const claudeDir = path.join(os.homedir(), '.claude'); - const configPath = path.join(claudeDir, 'project-config.json'); - - // Ensure the .claude directory exists - try { - await fs.mkdir(claudeDir, { recursive: true }); - } catch (error) { - if (error.code !== 'EEXIST') { - throw error; - } - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); -} - -// Resolve a Claude-encoded folder name back to an absolute project directory -// by inspecting cached metadata and JSONL `cwd` fields. Used only by the -// legacy name-based helpers below (`getSessions`, `deleteProject`, etc.) and -// by the conversation search; id-based routes use `getProjectPathById`. -async function extractProjectDirectory(projectName) { - // Check cache first - if (projectDirectoryCache.has(projectName)) { - return projectDirectoryCache.get(projectName); - } - - // Check project config for originalPath (manually added projects via UI or platform) - // This handles projects with dashes in their directory names correctly - - const config = await loadProjectConfig(); - if (config[projectName]?.originalPath) { - const originalPath = config[projectName].originalPath; - projectDirectoryCache.set(projectName, originalPath); - return originalPath; - } - - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - const cwdCounts = new Map(); - let latestTimestamp = 0; - let latestCwd = null; - let extractedPath; - - try { - // Check if the project directory exists - await fs.access(projectDir); - - const files = await fs.readdir(projectDir); - const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); - - if (jsonlFiles.length === 0) { - // Fall back to decoded project name if no sessions - extractedPath = projectName.replace(/-/g, '/'); - } else { - // Process all JSONL files to collect cwd values - for (const file of jsonlFiles) { - const jsonlFile = path.join(projectDir, file); - const fileStream = fsSync.createReadStream(jsonlFile); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity - }); - - for await (const line of rl) { - if (line.trim()) { - try { - const entry = JSON.parse(line); - - if (entry.cwd) { - // Count occurrences of each cwd - cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1); - - // Track the most recent cwd - const timestamp = new Date(entry.timestamp || 0).getTime(); - if (timestamp > latestTimestamp) { - latestTimestamp = timestamp; - latestCwd = entry.cwd; - } - } - } catch (parseError) { - // Skip malformed lines - } - } - } - } - - // Determine the best cwd to use - if (cwdCounts.size === 0) { - // No cwd found, fall back to decoded project name - extractedPath = projectName.replace(/-/g, '/'); - } else if (cwdCounts.size === 1) { - // Only one cwd, use it - extractedPath = Array.from(cwdCounts.keys())[0]; - } else { - // Multiple cwd values - prefer the most recent one if it has reasonable usage - const mostRecentCount = cwdCounts.get(latestCwd) || 0; - const maxCount = Math.max(...cwdCounts.values()); - - // Use most recent if it has at least 25% of the max count - if (mostRecentCount >= maxCount * 0.25) { - extractedPath = latestCwd; - } else { - // Otherwise use the most frequently used cwd - for (const [cwd, count] of cwdCounts.entries()) { - if (count === maxCount) { - extractedPath = cwd; - break; - } - } - } - - // Fallback (shouldn't reach here) - if (!extractedPath) { - extractedPath = latestCwd || projectName.replace(/-/g, '/'); - } - } - } - - // Cache the result - projectDirectoryCache.set(projectName, extractedPath); - - return extractedPath; - - } catch (error) { - // If the directory doesn't exist, just use the decoded project name - if (error.code === 'ENOENT') { - extractedPath = projectName.replace(/-/g, '/'); - } else { - console.error(`Error extracting project directory for ${projectName}:`, error); - // Fall back to decoded project name for other errors - extractedPath = projectName.replace(/-/g, '/'); - } - - // Cache the fallback result too - projectDirectoryCache.set(projectName, extractedPath); - - return extractedPath; - } -} async function getSessions(projectName, limit = 5, offset = 0) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); @@ -811,561 +651,6 @@ async function deleteCodexSession(sessionId) { } } -async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) { - const safeQuery = typeof query === 'string' ? query.trim() : ''; - const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200)); - const claudeDir = path.join(os.homedir(), '.claude', 'projects'); - const config = await loadProjectConfig(); - const results = []; - let totalMatches = 0; - const words = safeQuery.toLowerCase().split(/\s+/).filter(w => w.length > 0); - if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery }; - - const isAborted = () => signal?.aborted === true; - - const isSystemMessage = (textContent) => { - return typeof textContent === 'string' && ( - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('Caveat:') || - textContent.startsWith('This session is being continued from a previous') || - textContent.startsWith('Invalid API key') || - textContent.includes('{"subtasks":') || - textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || - textContent === 'Warmup' - ); - }; - - const extractText = (content) => { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .filter(part => part.type === 'text' && part.text) - .map(part => part.text) - .join(' '); - } - return ''; - }; - - const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const wordPatterns = words.map(w => new RegExp(`(? { - return wordPatterns.every(p => p.test(textLower)); - }; - - const buildSnippet = (text, textLower, snippetLen = 150) => { - let firstIndex = -1; - let firstWordLen = 0; - for (const w of words) { - const re = new RegExp(`(? 0 ? '...' : ''; - const suffix = end < text.length ? '...' : ''; - snippet = prefix + snippet + suffix; - const snippetLower = snippet.toLowerCase(); - const highlights = []; - for (const word of words) { - const re = new RegExp(`(? a.start - b.start); - const merged = []; - for (const h of highlights) { - const last = merged[merged.length - 1]; - if (last && h.start <= last.end) { - last.end = Math.max(last.end, h.end); - } else { - merged.push({ ...h }); - } - } - return { snippet, highlights: merged }; - }; - - try { - await fs.access(claudeDir); - const entries = await fs.readdir(claudeDir, { withFileTypes: true }); - const projectDirs = entries.filter(e => e.isDirectory()); - let scannedProjects = 0; - const totalProjects = projectDirs.length; - - for (const projectEntry of projectDirs) { - if (totalMatches >= safeLimit || isAborted()) break; - - const projectName = projectEntry.name; - const projectDir = path.join(claudeDir, projectName); - const displayName = config[projectName]?.displayName - || await generateDisplayName(projectName); - - let files; - try { - files = await fs.readdir(projectDir); - } catch { - continue; - } - - const jsonlFiles = files.filter( - file => file.endsWith('.jsonl') && !file.startsWith('agent-') - ); - - // Also include the DB `projectId` so the frontend (which now identifies - // projects by `projectId`) can match search results to the - // currently-loaded project list without a second round-trip. - let searchProjectId = null; - try { - const resolvedPath = await extractProjectDirectory(projectName); - const dbRow = projectsDb.getProjectPath(resolvedPath); - if (dbRow?.project_id) { - searchProjectId = dbRow.project_id; - } - } catch { - // Best-effort: if we cannot resolve the projectId, the result is still - // usable on the backend but the frontend will skip the auto-select. - } - - const projectResult = { - projectId: searchProjectId, - projectName, - projectDisplayName: displayName, - sessions: [] - }; - - for (const file of jsonlFiles) { - if (totalMatches >= safeLimit || isAborted()) break; - - const filePath = path.join(projectDir, file); - const sessionMatches = new Map(); - const sessionSummaries = new Map(); - const pendingSummaries = new Map(); - const sessionLastMessages = new Map(); - let currentSessionId = null; - - try { - const fileStream = fsSync.createReadStream(filePath); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity - }); - - for await (const line of rl) { - if (totalMatches >= safeLimit || isAborted()) break; - if (!line.trim()) continue; - - let entry; - try { - entry = JSON.parse(line); - } catch { - continue; - } - - if (entry.sessionId) { - currentSessionId = entry.sessionId; - } - if (entry.type === 'summary' && entry.summary) { - const sid = entry.sessionId || currentSessionId; - if (sid) { - sessionSummaries.set(sid, entry.summary); - } else if (entry.leafUuid) { - pendingSummaries.set(entry.leafUuid, entry.summary); - } - } - - // Apply pending summary via parentUuid - if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) { - const pending = pendingSummaries.get(entry.parentUuid); - if (pending) sessionSummaries.set(currentSessionId, pending); - } - - // Track last user/assistant message for fallback title - if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) { - const role = entry.message.role; - if (role === 'user' || role === 'assistant') { - const text = extractText(entry.message.content); - if (text && !isSystemMessage(text)) { - if (!sessionLastMessages.has(currentSessionId)) { - sessionLastMessages.set(currentSessionId, {}); - } - const msgs = sessionLastMessages.get(currentSessionId); - if (role === 'user') msgs.user = text; - else msgs.assistant = text; - } - } - } - - if (!entry.message?.content) continue; - if (entry.message.role !== 'user' && entry.message.role !== 'assistant') continue; - if (entry.isApiErrorMessage) continue; - - const text = extractText(entry.message.content); - if (!text || isSystemMessage(text)) continue; - - const textLower = text.toLowerCase(); - if (!allWordsMatch(textLower)) continue; - - const sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', ''); - if (!sessionMatches.has(sessionId)) { - sessionMatches.set(sessionId, []); - } - - const matches = sessionMatches.get(sessionId); - if (matches.length < 2) { - const { snippet, highlights } = buildSnippet(text, textLower); - matches.push({ - role: entry.message.role, - snippet, - highlights, - timestamp: entry.timestamp || null, - provider: 'claude', - messageUuid: entry.uuid || null - }); - totalMatches++; - } - } - } catch { - continue; - } - - for (const [sessionId, matches] of sessionMatches) { - projectResult.sessions.push({ - sessionId, - provider: 'claude', - sessionSummary: sessionSummaries.get(sessionId) || (() => { - const msgs = sessionLastMessages.get(sessionId); - const lastMsg = msgs?.user || msgs?.assistant; - return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session'; - })(), - matches - }); - } - } - - // Search Codex sessions for this project - try { - const actualProjectDir = await extractProjectDirectory(projectName); - if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { - await searchCodexSessionsForProject( - actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage, - buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted - ); - } - } catch { - // Skip codex search errors - } - - // Search Gemini sessions for this project - try { - const actualProjectDir = await extractProjectDirectory(projectName); - if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { - await searchGeminiSessionsForProject( - actualProjectDir, projectResult, words, allWordsMatch, - buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; } - ); - } - } catch { - // Skip gemini search errors - } - - scannedProjects++; - if (projectResult.sessions.length > 0) { - results.push(projectResult); - if (onProjectResult) { - onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects }); - } - } else if (onProjectResult && scannedProjects % 10 === 0) { - onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects }); - } - } - } catch { - // claudeDir doesn't exist - } - - return { results, totalMatches, query: safeQuery }; -} - -async function searchCodexSessionsForProject( - projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage, - buildSnippet, limit, getTotalMatches, addMatches, isAborted -) { - const normalizedProjectPath = normalizeComparablePath(projectPath); - if (!normalizedProjectPath) return; - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - try { - await fs.access(codexSessionsDir); - } catch { - return; - } - - const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); - - for (const filePath of jsonlFiles) { - if (getTotalMatches() >= limit || isAborted()) break; - - try { - const fileStream = fsSync.createReadStream(filePath); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - // First pass: read session_meta to check project path match - let sessionMeta = null; - for await (const line of rl) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - if (entry.type === 'session_meta' && entry.payload) { - sessionMeta = entry.payload; - break; - } - } catch { continue; } - } - - // Skip sessions that don't belong to this project - if (!sessionMeta) continue; - const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd); - if (sessionProjectPath !== normalizedProjectPath) continue; - - // Second pass: re-read file to find matching messages - const fileStream2 = fsSync.createReadStream(filePath); - const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity }); - let latestUserMessageText = null; - const matches = []; - - for await (const line of rl2) { - if (getTotalMatches() >= limit || isAborted()) break; - if (!line.trim()) continue; - - let entry; - try { entry = JSON.parse(line); } catch { continue; } - - let text = null; - let role = null; - - if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) { - text = entry.payload.message; - role = 'user'; - latestUserMessageText = text; - } else if (entry.type === 'response_item' && entry.payload?.type === 'message') { - const contentParts = entry.payload.content || []; - if (entry.payload.role === 'user') { - text = contentParts - .filter(p => p.type === 'input_text' && p.text) - .map(p => p.text) - .join(' '); - role = 'user'; - if (text) latestUserMessageText = text; - } else if (entry.payload.role === 'assistant') { - text = contentParts - .filter(p => p.type === 'output_text' && p.text) - .map(p => p.text) - .join(' '); - role = 'assistant'; - } - } - - if (!text || !role) continue; - const textLower = text.toLowerCase(); - if (!allWordsMatch(textLower)) continue; - - if (matches.length < 2) { - const { snippet, highlights } = buildSnippet(text, textLower); - matches.push({ role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'codex' }); - addMatches(1); - } - } - - if (matches.length > 0) { - projectResult.sessions.push({ - sessionId: sessionMeta.id, - provider: 'codex', - sessionSummary: latestUserMessageText - ? (latestUserMessageText.length > 50 ? latestUserMessageText.substring(0, 50) + '...' : latestUserMessageText) - : 'Codex Session', - matches - }); - } - } catch { - continue; - } - } -} - -async function searchGeminiSessionsForProject( - projectPath, projectResult, words, allWordsMatch, - buildSnippet, limit, getTotalMatches, addMatches -) { - // 1) Search in-memory sessions (created via UI) - for (const [sessionId, session] of sessionManager.sessions) { - if (getTotalMatches() >= limit) break; - if (session.projectPath !== projectPath) continue; - - const matches = []; - for (const msg of session.messages) { - if (getTotalMatches() >= limit) break; - if (msg.role !== 'user' && msg.role !== 'assistant') continue; - - const text = typeof msg.content === 'string' ? msg.content - : Array.isArray(msg.content) ? msg.content.filter(p => p.type === 'text').map(p => p.text).join(' ') - : ''; - if (!text) continue; - - const textLower = text.toLowerCase(); - if (!allWordsMatch(textLower)) continue; - - if (matches.length < 2) { - const { snippet, highlights } = buildSnippet(text, textLower); - matches.push({ - role: msg.role, snippet, highlights, - timestamp: msg.timestamp ? msg.timestamp.toISOString() : null, - provider: 'gemini' - }); - addMatches(1); - } - } - - if (matches.length > 0) { - const firstUserMsg = session.messages.find(m => m.role === 'user'); - const summary = firstUserMsg?.content - ? (typeof firstUserMsg.content === 'string' - ? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content) - : 'Gemini Session') - : 'Gemini Session'; - - projectResult.sessions.push({ - sessionId, - provider: 'gemini', - sessionSummary: summary, - matches - }); - } - } - - // 2) Search Gemini CLI sessions on disk (~/.gemini/tmp//chats/*.json) - const normalizedProjectPath = normalizeComparablePath(projectPath); - if (!normalizedProjectPath) return; - - const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); - try { - await fs.access(geminiTmpDir); - } catch { - return; - } - - const trackedSessionIds = new Set(); - for (const [sid] of sessionManager.sessions) { - trackedSessionIds.add(sid); - } - - let projectDirs; - try { - projectDirs = await fs.readdir(geminiTmpDir); - } catch { - return; - } - - for (const projectDir of projectDirs) { - if (getTotalMatches() >= limit) break; - - const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root'); - let projectRoot; - try { - projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim(); - } catch { - continue; - } - - if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue; - - const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); - let chatFiles; - try { - chatFiles = await fs.readdir(chatsDir); - } catch { - continue; - } - - for (const chatFile of chatFiles) { - if (getTotalMatches() >= limit) break; - if (!chatFile.endsWith('.json')) continue; - - try { - const filePath = path.join(chatsDir, chatFile); - const data = await fs.readFile(filePath, 'utf8'); - const session = JSON.parse(data); - if (!session.messages || !Array.isArray(session.messages)) continue; - - const cliSessionId = session.sessionId || chatFile.replace('.json', ''); - if (trackedSessionIds.has(cliSessionId)) continue; - - const matches = []; - let firstUserText = null; - - for (const msg of session.messages) { - if (getTotalMatches() >= limit) break; - - const role = msg.type === 'user' ? 'user' - : (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant' - : null; - if (!role) continue; - - let text = ''; - if (typeof msg.content === 'string') { - text = msg.content; - } else if (Array.isArray(msg.content)) { - text = msg.content - .filter(p => p.text) - .map(p => p.text) - .join(' '); - } - if (!text) continue; - - if (role === 'user' && !firstUserText) firstUserText = text; - - const textLower = text.toLowerCase(); - if (!allWordsMatch(textLower)) continue; - - if (matches.length < 2) { - const { snippet, highlights } = buildSnippet(text, textLower); - matches.push({ - role, snippet, highlights, - timestamp: msg.timestamp || null, - provider: 'gemini' - }); - addMatches(1); - } - } - - if (matches.length > 0) { - const summary = firstUserText - ? (firstUserText.length > 50 ? firstUserText.substring(0, 50) + '...' : firstUserText) - : 'Gemini CLI Session'; - - projectResult.sessions.push({ - sessionId: cliSessionId, - provider: 'gemini', - sessionSummary: summary, - matches - }); - } - } catch { - continue; - } - } - } -} - // Only functions with consumers outside this module are exported. Folder-name // based helpers (`getSessions`, `deleteSession`, etc.) are kept as internal // implementation details of the id-based wrappers below. @@ -1373,6 +658,5 @@ export { deleteSessionById, getProjectPathById, claudeFolderNameFromPath, - deleteCodexSession, - searchConversations + deleteCodexSession }; diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index d75cfdfd..90d90833 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -40,7 +40,7 @@ type ConversationSession = { }; type ConversationProjectResult = { - // Emitted by server/projects.js#searchConversations so the sidebar can map a + // Emitted by the provider search service so the sidebar can map a // match back to the Project in its current state by projectId. projectId: string | null; projectName: string; diff --git a/src/utils/api.js b/src/utils/api.js index 3dcae051..0ac8d426 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -100,7 +100,7 @@ export const api = { const token = localStorage.getItem('auth-token'); const params = new URLSearchParams({ q: query, limit: String(limit) }); if (token) params.set('token', token); - return `/api/search/conversations?${params.toString()}`; + return `/api/providers/search/sessions?${params.toString()}`; }, createProject: (projectData) => authenticatedFetch('/api/projects/create-project', {