import fsSync, { promises as fs } from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; import { spawn } from 'cross-spawn'; import { rgPath } from '@vscode/ripgrep'; import { projectsDb, sessionsDb } from '@/modules/database/index.js'; type AnyRecord = Record; type SearchableProvider = 'claude' | 'codex' | 'gemini'; type SearchSnippetHighlight = { start: number; end: number; }; type SessionConversationMatch = { role: string; snippet: string; highlights: SearchSnippetHighlight[]; timestamp: string | null; provider: SearchableProvider; messageUuid?: string | null; }; type SessionConversationResult = { sessionId: string; provider: SearchableProvider; 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; }; type SessionRepositoryRow = ReturnType[number]; type SearchableSessionRow = SessionRepositoryRow & { provider: SearchableProvider; jsonl_path: string; }; type SearchRuntime = { matchesQuery: (text: string) => boolean; buildSnippet: (text: string) => { snippet: string; highlights: SearchSnippetHighlight[] }; limit: number; totalMatches: number; isAborted: () => boolean; matchedSessionKeys: Set; claudeSessionsByFileKey: Map; claudeFileResultsCache: Map>; }; type SearchablePathEntry = { normalizedPath: string; absolutePath: string; }; type ProjectBucket = { key: string; projectId: string | null; projectName: string; projectDisplayName: string; sessions: SearchableSessionRow[]; }; const SUPPORTED_PROVIDERS = new Set(['claude', 'codex', 'gemini']); const MAX_MATCHES_PER_SESSION = 2; const RIPGREP_FILE_CHUNK_SIZE = 40; const RIPGREP_CHUNK_CONCURRENCY = 6; const UNKNOWN_PROJECT_KEY = '__unknown_project__'; const INTERNAL_CONTENT_PREFIXES = [ '', '', '', '', '', 'Caveat:', 'This session is being continued from a previous', 'Invalid API key', '[Request interrupted', ] as const; /** * Codex includes extra internal metadata tags that should not surface as * user-facing searchable conversation content. */ const CODEX_INTERNAL_CONTENT_PREFIXES = [ '', '', ] as const; 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; } function chunkArray(items: TItem[], size: number): TItem[][] { if (size <= 0) { return [items]; } const chunks: TItem[][] = []; for (let idx = 0; idx < items.length; idx += size) { chunks.push(items.slice(idx, idx + size)); } return chunks; } function getSessionKey(session: Pick): string { return `${session.provider}:${session.session_id}`; } function makeProjectKey(projectPath: string | null): string { const normalized = typeof projectPath === 'string' ? projectPath.trim() : ''; return normalized.length > 0 ? normalized : UNKNOWN_PROJECT_KEY; } function toSummaryText(customName: string | null, fallback: string | null | undefined, emptyLabel: string): string { const trimmedCustomName = typeof customName === 'string' ? customName.trim() : ''; if (trimmedCustomName) { return trimmedCustomName; } const trimmedFallback = typeof fallback === 'string' ? fallback.trim() : ''; if (!trimmedFallback) { return emptyLabel; } return trimmedFallback.length > 50 ? `${trimmedFallback.slice(0, 50)}...` : trimmedFallback; } function isInternalContent(content: string): boolean { return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix)); } function isInternalCodexContent(content: string): boolean { const normalized = content.trimStart(); return CODEX_INTERNAL_CONTENT_PREFIXES.some((prefix) => normalized.startsWith(prefix)); } function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function createWordMatcher( rawQuery: string, words: string[], ): Pick { const normalizedQuery = rawQuery.trim().replace(/\s+/g, ' '); const requireExactPhrase = words.length > 1 && normalizedQuery.length > 0; const wordPatterns = words.map((word) => new RegExp(`(? escapeRegex(word)).join('\\s+'); const phraseRegex = new RegExp(phrasePattern, 'iu'); const allWordsMatch = (textLower: string): boolean => wordPatterns.every((pattern) => pattern.test(textLower)); const matchesQuery = (text: string): boolean => { if (typeof text !== 'string' || text.length === 0) { return false; } if (requireExactPhrase) { return phraseRegex.test(text); } if (phraseRegex.test(text) || words.length === 1) { return true; } return allWordsMatch(text.toLowerCase()); }; const buildSnippet = ( text: string, snippetLen = 150, ): { snippet: string; highlights: SearchSnippetHighlight[] } => { const textLower = text.toLowerCase(); let firstIndex = -1; let firstWordLen = 0; let phraseStart = -1; let phraseLength = 0; const phraseMatch = phraseRegex.exec(text); if (phraseMatch) { phraseStart = phraseMatch.index; phraseLength = phraseMatch[0].length; firstIndex = phraseStart; firstWordLen = phraseLength; } if (firstIndex === -1) { for (const word of words) { const regex = new RegExp(`(? 0 ? '...' : ''; const suffix = end < text.length ? '...' : ''; const snippetBody = text.slice(start, end).replace(/\n/g, ' '); const snippet = `${prefix}${snippetBody}${suffix}`; const snippetLower = snippet.toLowerCase(); const highlights: SearchSnippetHighlight[] = []; if (phraseStart >= start && phraseStart + phraseLength <= end) { const phraseOffset = prefix.length + (phraseStart - start); highlights.push({ start: phraseOffset, end: phraseOffset + phraseLength, }); } if (!requireExactPhrase) { 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 }; }; return { matchesQuery, buildSnippet }; } function extractClaudeText(content: unknown): string { if (typeof content === 'string') { return content; } if (!Array.isArray(content)) { return ''; } return content .filter((part: AnyRecord) => part?.type === 'text' && typeof part?.text === 'string') .map((part: AnyRecord) => String(part.text)) .join(' '); } function extractCodexText(content: unknown): string { if (typeof content === 'string') { return content; } if (!Array.isArray(content)) { return ''; } return content .map((item) => { if (!item || typeof item !== 'object') { return ''; } const record = item as AnyRecord; if ( (record.type === 'input_text' || record.type === 'output_text' || record.type === 'text') && typeof record.text === 'string' ) { return record.text; } return ''; }) .filter(Boolean) .join(' '); } function extractGeminiText(content: unknown): string { if (typeof content === 'string') { return content; } if (!Array.isArray(content)) { return ''; } return content .filter((part: AnyRecord) => typeof part?.text === 'string') .map((part: AnyRecord) => String(part.text)) .join(' '); } function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSessionRow[] { const normalizedRows: SearchableSessionRow[] = []; for (const row of rows) { const provider = row.provider as SearchableProvider; if (!SUPPORTED_PROVIDERS.has(provider)) { continue; } const rawJsonlPath = typeof row.jsonl_path === 'string' ? row.jsonl_path.trim() : ''; if (!rawJsonlPath) { continue; } const absoluteJsonlPath = path.resolve(rawJsonlPath); if (!fsSync.existsSync(absoluteJsonlPath)) { continue; } normalizedRows.push({ ...row, provider, jsonl_path: absoluteJsonlPath, }); } return normalizedRows; } function buildProjectBuckets(searchableSessions: SearchableSessionRow[]): ProjectBucket[] { const projectBuckets = new Map(); const projectMetadataCache = new Map(); for (const session of searchableSessions) { const key = makeProjectKey(session.project_path); if (!projectBuckets.has(key)) { if (!projectMetadataCache.has(key)) { if (key === UNKNOWN_PROJECT_KEY) { projectMetadataCache.set(key, { projectId: null, projectDisplayName: 'Unknown Project', }); } else { const projectRow = projectsDb.getProjectPath(key); const customProjectName = typeof projectRow?.custom_project_name === 'string' ? projectRow.custom_project_name.trim() : ''; const displayName = customProjectName || path.basename(key) || key; projectMetadataCache.set(key, { projectId: projectRow?.project_id ?? null, projectDisplayName: displayName, }); } } const metadata = projectMetadataCache.get(key) as { projectId: string | null; projectDisplayName: string }; projectBuckets.set(key, { key, projectId: metadata.projectId, projectName: key, projectDisplayName: metadata.projectDisplayName, sessions: [], }); } const bucket = projectBuckets.get(key) as ProjectBucket; bucket.sessions.push(session); } const buckets = Array.from(projectBuckets.values()); for (const bucket of buckets) { bucket.sessions.sort((left, right) => { const leftTs = new Date(left.updated_at || left.created_at || 0).getTime(); const rightTs = new Date(right.updated_at || right.created_at || 0).getTime(); return rightTs - leftTs; }); } return buckets; } /** * Executes ripgrep with the file list explicitly provided from sessionsDb jsonl paths. * * This avoids recursive directory walks and uses a fixed known candidate list. */ async function runRipgrepFilesWithMatches( pattern: string, filePaths: string[], signal?: AbortSignal, ): Promise> { if (!pattern || filePaths.length === 0 || signal?.aborted) { return new Set(); } return new Promise((resolve, reject) => { const args = [ '--files-with-matches', '--no-messages', '--ignore-case', '--fixed-strings', '--', pattern, ...filePaths, ]; const rg = spawn(rgPath, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let aborted = false; const abortListener = () => { aborted = true; rg.kill(); }; if (signal) { signal.addEventListener('abort', abortListener, { once: true }); } rg.stdout.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk); }); rg.stderr.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); }); rg.on('error', (error) => { if (signal) { signal.removeEventListener('abort', abortListener); } if (aborted || signal?.aborted) { resolve(new Set()); return; } reject(error); }); rg.on('close', (code) => { if (signal) { signal.removeEventListener('abort', abortListener); } if (aborted || signal?.aborted) { resolve(new Set()); return; } if (code !== 0 && code !== 1) { const stderr = Buffer.concat(stderrChunks).toString('utf8').trim(); reject(new Error(`ripgrep failed with code ${String(code)}: ${stderr}`)); return; } const stdout = Buffer.concat(stdoutChunks).toString('utf8'); const matchedPaths = new Set(); for (const line of stdout.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed) { continue; } matchedPaths.add(normalizeComparablePath(trimmed)); } resolve(matchedPaths); }); }); } async function findMatchedFileKeys( searchablePathEntries: SearchablePathEntry[], rawQuery: string, words: string[], signal?: AbortSignal, ): Promise> { if (searchablePathEntries.length === 0 || words.length === 0 || signal?.aborted) { return new Set(); } const normalizedQuery = rawQuery.trim().replace(/\s+/g, ' '); const requireExactPhrase = words.length > 1 && normalizedQuery.length > 0; if (requireExactPhrase) { const matchedForPhrase = new Set(); const fileChunks = chunkArray( searchablePathEntries.map((entry) => entry.absolutePath), RIPGREP_FILE_CHUNK_SIZE, ); let nextChunkIndex = 0; const workerCount = Math.min(RIPGREP_CHUNK_CONCURRENCY, fileChunks.length); const workers = Array.from({ length: workerCount }, async () => { while (nextChunkIndex < fileChunks.length && !signal?.aborted) { const currentIndex = nextChunkIndex; nextChunkIndex += 1; const chunkMatches = await runRipgrepFilesWithMatches(normalizedQuery, fileChunks[currentIndex], signal); for (const matchedPath of chunkMatches) { matchedForPhrase.add(matchedPath); } } }); await Promise.all(workers); if (signal?.aborted) { return new Set(); } return matchedForPhrase; } let remainingEntries = searchablePathEntries.slice(); // Run one ripgrep pass per term and intersect by keeping only files that // matched every query word. for (const word of words) { if (signal?.aborted) { return new Set(); } const matchedForWord = new Set(); const fileChunks = chunkArray( remainingEntries.map((entry) => entry.absolutePath), RIPGREP_FILE_CHUNK_SIZE, ); let nextChunkIndex = 0; const workerCount = Math.min(RIPGREP_CHUNK_CONCURRENCY, fileChunks.length); const workers = Array.from({ length: workerCount }, async () => { while (nextChunkIndex < fileChunks.length && !signal?.aborted) { const currentIndex = nextChunkIndex; nextChunkIndex += 1; const chunkMatches = await runRipgrepFilesWithMatches(word, fileChunks[currentIndex], signal); for (const matchedPath of chunkMatches) { matchedForWord.add(matchedPath); } } }); await Promise.all(workers); if (signal?.aborted) { return new Set(); } remainingEntries = remainingEntries.filter((entry) => matchedForWord.has(entry.normalizedPath)); if (remainingEntries.length === 0) { break; } } return new Set(remainingEntries.map((entry) => entry.normalizedPath)); } function addSessionMatch( runtime: SearchRuntime, matches: SessionConversationMatch[], match: SessionConversationMatch, ): void { if (runtime.totalMatches >= runtime.limit || matches.length >= MAX_MATCHES_PER_SESSION) { return; } matches.push(match); runtime.totalMatches += 1; } async function parseClaudeSessionMatches( session: SearchableSessionRow, runtime: SearchRuntime, ): Promise { const fileKey = normalizeComparablePath(session.jsonl_path); if (!fileKey) { return null; } if (!runtime.claudeFileResultsCache.has(fileKey)) { const sessionsForFile = runtime.claudeSessionsByFileKey.get(fileKey) || []; const matchedSessionsForFile = sessionsForFile.filter((candidate) => runtime.matchedSessionKeys.has(getSessionKey(candidate)), ); const targetSessions = matchedSessionsForFile.length > 0 ? matchedSessionsForFile : [session]; const targetSessionIds = new Set(targetSessions.map((candidate) => candidate.session_id)); const customNameBySessionId = new Map(); for (const candidate of targetSessions) { customNameBySessionId.set(candidate.session_id, candidate.custom_name ?? null); } type ClaudeSessionSearchState = { matches: SessionConversationMatch[]; pendingSummaries: Map; fallbackUserText: string | null; fallbackAssistantText: string | null; resolvedSummary: string | null; }; const sessionStateById = new Map(); const getSessionState = (sessionId: string): ClaudeSessionSearchState => { if (!sessionStateById.has(sessionId)) { sessionStateById.set(sessionId, { matches: [], pendingSummaries: new Map(), fallbackUserText: null, fallbackAssistantText: null, resolvedSummary: null, }); } return sessionStateById.get(sessionId) as ClaudeSessionSearchState; }; let currentSessionId: string | null = null; try { const fileStream = fsSync.createReadStream(session.jsonl_path); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (runtime.totalMatches >= runtime.limit || runtime.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); } const entrySessionId = entry.sessionId ? String(entry.sessionId) : currentSessionId; if (!entrySessionId || !targetSessionIds.has(entrySessionId)) { continue; } const state = getSessionState(entrySessionId); if (entry.type === 'summary' && entry.summary) { const summaryValue = String(entry.summary); if (entry.sessionId) { state.resolvedSummary = summaryValue; } else if (entry.leafUuid) { state.pendingSummaries.set(String(entry.leafUuid), summaryValue); } } if (!state.resolvedSummary && entry.parentUuid) { const pendingSummary = state.pendingSummaries.get(String(entry.parentUuid)); if (pendingSummary) { state.resolvedSummary = pendingSummary; } } if (!entry.message?.content || entry.isApiErrorMessage) { continue; } const role = entry.message.role; if (role !== 'user' && role !== 'assistant') { continue; } const text = extractClaudeText(entry.message.content); if (!text || isInternalContent(text)) { continue; } if (role === 'user') { state.fallbackUserText = text; } else { state.fallbackAssistantText = text; } if (!runtime.matchesQuery(text)) { continue; } const { snippet, highlights } = runtime.buildSnippet(text); addSessionMatch(runtime, state.matches, { role, snippet, highlights, timestamp: entry.timestamp ? String(entry.timestamp) : null, provider: 'claude', messageUuid: entry.uuid ? String(entry.uuid) : null, }); } } catch { runtime.claudeFileResultsCache.set(fileKey, new Map()); return null; } const fileResults = new Map(); for (const [sessionId, state] of sessionStateById.entries()) { if (state.matches.length === 0) { continue; } fileResults.set(sessionId, { sessionId, provider: 'claude', sessionSummary: toSummaryText( customNameBySessionId.get(sessionId) ?? null, state.resolvedSummary || state.fallbackUserText || state.fallbackAssistantText, 'New Session', ), matches: state.matches, }); } runtime.claudeFileResultsCache.set(fileKey, fileResults); } return runtime.claudeFileResultsCache.get(fileKey)?.get(session.session_id) ?? null; } function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boolean { if (!payload || payload.type !== 'user_message') { return false; } if (payload.kind && payload.kind !== 'plain') { return false; } return typeof payload.message === 'string' && payload.message.trim().length > 0; } async function parseCodexSessionMatches( session: SearchableSessionRow, runtime: SearchRuntime, ): Promise { const matches: SessionConversationMatch[] = []; let latestUserMessageText: string | null = null; const seenMessageFingerprints = new Set(); try { const fileStream = fsSync.createReadStream(session.jsonl_path); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (runtime.totalMatches >= runtime.limit || runtime.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: 'user' | 'assistant' | null = null; if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) { text = String(entry.payload.message); role = 'user'; } else if ( entry.type === 'event_msg' && entry.payload?.type === 'agent_reasoning' && typeof entry.payload?.text === 'string' ) { text = String(entry.payload.text); role = 'assistant'; } else if (entry.type === 'response_item' && entry.payload?.type === 'message') { const payload = entry.payload as AnyRecord; if (payload.role === 'user') { text = extractCodexText(payload.content); role = 'user'; } else if (payload.role === 'assistant') { text = extractCodexText(payload.content); role = 'assistant'; } } else if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') { const summaryText = Array.isArray(entry.payload.summary) ? entry.payload.summary .map((item: AnyRecord) => (typeof item?.text === 'string' ? item.text : '')) .filter(Boolean) .join('\n') : ''; if (summaryText.trim()) { text = summaryText; role = 'assistant'; } } if (!text || !role) { continue; } if (isInternalCodexContent(text)) { continue; } if (role === 'user') { latestUserMessageText = text; } const fingerprint = `${role}:${text.trim().toLowerCase()}`; if (seenMessageFingerprints.has(fingerprint)) { continue; } seenMessageFingerprints.add(fingerprint); if (!runtime.matchesQuery(text)) { continue; } const { snippet, highlights } = runtime.buildSnippet(text); addSessionMatch(runtime, matches, { role, snippet, highlights, timestamp: entry.timestamp ? String(entry.timestamp) : null, provider: 'codex', }); } } catch { return null; } if (matches.length === 0) { return null; } return { sessionId: session.session_id, provider: 'codex', sessionSummary: toSummaryText(session.custom_name, latestUserMessageText, 'Codex Session'), matches, }; } async function parseGeminiSessionMatches( session: SearchableSessionRow, runtime: SearchRuntime, ): Promise { let data: string; try { data = await fs.readFile(session.jsonl_path, 'utf8'); } catch { return null; } let parsed: AnyRecord; try { parsed = JSON.parse(data) as AnyRecord; } catch { return null; } const sourceMessages = Array.isArray(parsed.messages) ? parsed.messages as AnyRecord[] : []; if (sourceMessages.length === 0) { return null; } const matches: SessionConversationMatch[] = []; let firstUserText: string | null = null; for (const msg of sourceMessages) { if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { break; } const role = msg.type === 'user' ? 'user' : (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant' : null; if (!role) { continue; } const text = extractGeminiText(msg.content); if (!text) { continue; } if (role === 'user' && !firstUserText) { firstUserText = text; } if (!runtime.matchesQuery(text)) { continue; } const { snippet, highlights } = runtime.buildSnippet(text); addSessionMatch(runtime, matches, { role, snippet, highlights, timestamp: msg.timestamp ? String(msg.timestamp) : null, provider: 'gemini', }); } if (matches.length === 0) { return null; } return { sessionId: session.session_id, provider: 'gemini', sessionSummary: toSummaryText(session.custom_name, firstUserText, 'Gemini Session'), matches, }; } async function parseSessionMatches( session: SearchableSessionRow, runtime: SearchRuntime, ): Promise { if (session.provider === 'claude') { return parseClaudeSessionMatches(session, runtime); } if (session.provider === 'codex') { return parseCodexSessionMatches(session, runtime); } return parseGeminiSessionMatches(session, runtime); } 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 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; if (isAborted()) { return { results: [], totalMatches: 0, query: safeQuery }; } const searchableSessions = normalizeSearchableSessions(sessionsDb.getAllSessions()); if (searchableSessions.length === 0) { return { results: [], totalMatches: 0, query: safeQuery }; } const sessionsByPathKey = new Map(); const searchablePathEntries: SearchablePathEntry[] = []; for (const session of searchableSessions) { const normalizedPath = normalizeComparablePath(session.jsonl_path); if (!normalizedPath) { continue; } if (!sessionsByPathKey.has(normalizedPath)) { sessionsByPathKey.set(normalizedPath, []); searchablePathEntries.push({ normalizedPath, absolutePath: session.jsonl_path, }); } const pathSessions = sessionsByPathKey.get(normalizedPath) as SearchableSessionRow[]; pathSessions.push(session); } const matchedFileKeys = await findMatchedFileKeys( searchablePathEntries, safeQuery, words, signal ?? undefined, ); if (isAborted() || matchedFileKeys.size === 0) { return { results: [], totalMatches: 0, query: safeQuery }; } const matchedSessionKeys = new Set(); for (const fileKey of matchedFileKeys) { const sessions = sessionsByPathKey.get(fileKey); if (!sessions) { continue; } for (const session of sessions) { matchedSessionKeys.add(getSessionKey(session)); } } const projectBuckets = buildProjectBuckets(searchableSessions); const totalProjects = projectBuckets.length; const results: ProjectConversationResult[] = []; let scannedProjects = 0; const runtime: SearchRuntime = { ...createWordMatcher(safeQuery, words), limit: safeLimit, totalMatches: 0, isAborted, matchedSessionKeys, claudeSessionsByFileKey: new Map(), claudeFileResultsCache: new Map>(), }; for (const [fileKey, sessions] of sessionsByPathKey.entries()) { const claudeSessions = sessions.filter((session) => session.provider === 'claude'); if (claudeSessions.length > 0) { runtime.claudeSessionsByFileKey.set(fileKey, claudeSessions); } } for (const bucket of projectBuckets) { if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { break; } const projectResult: ProjectConversationResult = { projectId: bucket.projectId, projectName: bucket.projectName, projectDisplayName: bucket.projectDisplayName, sessions: [], }; for (const session of bucket.sessions) { if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) { break; } if (!matchedSessionKeys.has(getSessionKey(session))) { continue; } const sessionResult = await parseSessionMatches(session, runtime); if (sessionResult) { projectResult.sessions.push(sessionResult); } } scannedProjects += 1; if (projectResult.sessions.length > 0) { results.push(projectResult); onProjectResult?.({ projectResult, totalMatches: runtime.totalMatches, scannedProjects, totalProjects, }); } else if (onProjectResult && scannedProjects % 10 === 0) { onProjectResult({ projectResult: null, totalMatches: runtime.totalMatches, scannedProjects, totalProjects, }); } } return { results, totalMatches: runtime.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, ); }, };