/** * SessionSearcher - Searches sessions for query strings. * * Responsibilities: * - Search across sessions in a project * - Search within a single session file * - Restrict matching scope to User text + AI last text output * - Extract context around each match occurrence * * Uses SearchTextExtractor for lightweight text extraction (skips ChunkBuilder) * and SearchTextCache for mtime-based caching of extracted entries. */ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { parseJsonlFile } from '@main/utils/jsonl'; import { extractSessionId } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; import { startMainSpan } from '../../sentry'; import { resolveProjectStorageDir } from './projectStorageDir'; import { SearchTextCache } from './SearchTextCache'; import { extractSearchableEntries } from './SearchTextExtractor'; import { subprojectRegistry } from './SubprojectRegistry'; import type { SearchableEntry } from './SearchTextExtractor'; import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; import type { SearchResult, SearchSessionsResult } from '@main/types'; const logger = createLogger('Discovery:SessionSearcher'); const SSH_FAST_SEARCH_STAGE_LIMITS = [40, 140, 320] as const; const SSH_FAST_SEARCH_MIN_RESULTS = 8; const SSH_FAST_SEARCH_TIME_BUDGET_MS = 4500; /** * SessionSearcher provides methods for searching sessions. */ export class SessionSearcher { private readonly projectsDir: string; private readonly fsProvider: FileSystemProvider; private readonly searchCache: SearchTextCache; constructor(projectsDir: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir; this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); this.searchCache = new SearchTextCache(); } /** * Searches sessions in a project for a query string. * Filters out noise messages and returns matching content. * * @param projectId - The project ID to search in * @param query - Search query string * @param maxResults - Maximum number of results to return (default 50) * @returns Search results with matches and metadata */ async searchSessions( projectId: string, query: string, maxResults: number = 50 ): Promise { return startMainSpan('session.search', 'search', async () => { const startedAt = Date.now(); const results: SearchResult[] = []; let sessionsSearched = 0; const fastMode = this.fsProvider.type === 'ssh'; let isPartial = false; if (!query || query.trim().length === 0) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } const normalizedQuery = query.toLowerCase().trim(); try { const projectPath = await resolveProjectStorageDir( this.projectsDir, projectId, this.fsProvider ); const sessionFilter = subprojectRegistry.getSessionFilter(projectId); if (!projectPath) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } // Get all session files const entries = await this.fsProvider.readdir(projectPath); const sessionEntries = entries.filter((entry) => { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) return false; // Filter to only sessions belonging to this subproject if (sessionFilter) { const sessionId = extractSessionId(entry.name); return sessionFilter.has(sessionId); } return true; }); const sessionFiles = await this.collectFulfilledInBatches( sessionEntries, this.fsProvider.type === 'ssh' ? 24 : 128, async (entry) => { const filePath = path.join(projectPath, entry.name); const mtimeMs = typeof entry.mtimeMs === 'number' ? entry.mtimeMs : (await this.fsProvider.stat(filePath)).mtimeMs; return { name: entry.name, filePath, mtimeMs }; } ); sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); // Search session files with bounded concurrency and staged breadth in SSH mode. const searchBatchSize = fastMode ? 3 : 16; const stageBoundaries = fastMode ? this.buildFastSearchStageBoundaries(sessionFiles.length) : [sessionFiles.length]; let searchedUntil = 0; let shouldStop = false; for (const stageBoundary of stageBoundaries) { for ( let i = searchedUntil; i < stageBoundary && results.length < maxResults; i += searchBatchSize ) { if (fastMode && Date.now() - startedAt >= SSH_FAST_SEARCH_TIME_BUDGET_MS) { isPartial = true; shouldStop = true; break; } const batch = sessionFiles.slice(i, i + searchBatchSize); sessionsSearched += batch.length; const settled = await Promise.allSettled( batch.map(async (file) => { const sessionId = extractSessionId(file.name); return this.searchSessionFile( projectId, sessionId, file.filePath, normalizedQuery, maxResults, file.mtimeMs ); }) ); for (const result of settled) { if (results.length >= maxResults) { break; } if (result.status !== 'fulfilled' || result.value.length === 0) { continue; } const remaining = maxResults - results.length; results.push(...result.value.slice(0, remaining)); } } searchedUntil = stageBoundary; if (shouldStop || !fastMode || results.length >= maxResults) { break; } if ( stageBoundary < sessionFiles.length && results.length >= SSH_FAST_SEARCH_MIN_RESULTS ) { isPartial = true; break; } } if (fastMode && results.length < maxResults && sessionsSearched < sessionFiles.length) { isPartial = true; } if (fastMode) { logger.debug( `SSH fast search scanned ${sessionsSearched}/${sessionFiles.length} sessions in ${Date.now() - startedAt}ms (results=${results.length}, partial=${isPartial})` ); } return { results, totalMatches: results.length, sessionsSearched, query, isPartial: fastMode ? isPartial : undefined, }; } catch (error) { logger.error(`Error searching sessions for project ${projectId}:`, error); return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } }); // startMainSpan } /** * Searches a single session file for a query string. * * Uses SearchTextExtractor for lightweight text extraction (no ChunkBuilder) * and SearchTextCache for mtime-based caching. * * @param projectId - The project ID * @param sessionId - The session ID * @param filePath - Path to the session file * @param query - Normalized search query (lowercase) * @param maxResults - Maximum number of results to return * @param mtimeMs - File modification time for cache invalidation * @returns Array of search results */ async searchSessionFile( projectId: string, sessionId: string, filePath: string, query: string, maxResults: number, mtimeMs: number ): Promise { const results: SearchResult[] = []; // Check cache first let cached = this.searchCache.get(filePath, mtimeMs); if (!cached) { // Cache miss — parse and extract const messages = await parseJsonlFile(filePath, this.fsProvider); const extracted = extractSearchableEntries(messages); this.searchCache.set(filePath, mtimeMs, extracted.entries, extracted.sessionTitle); cached = extracted; } const { entries, sessionTitle } = cached; // Fast pre-filter: skip sessions where no entry contains the query in raw text const hasAnyMatch = entries.some((entry) => entry.text.toLowerCase().includes(query)); if (!hasAnyMatch) return results; for (const entry of entries) { if (results.length >= maxResults) break; this.collectMatchesForEntry( entry, query, results, maxResults, projectId, sessionId, sessionTitle ); } return results; } private collectMatchesForEntry( entry: SearchableEntry, query: string, results: SearchResult[], maxResults: number, projectId: string, sessionId: string, sessionTitle?: string ): void { // Plain indexOf search — no markdown/remark parsing const lowerText = entry.text.toLowerCase(); if (!lowerText.includes(query)) return; // Use raw text directly for context snippets let pos = 0; let matchIndex = 0; while ((pos = lowerText.indexOf(query, pos)) !== -1) { if (results.length >= maxResults) return; const contextStart = Math.max(0, pos - 50); const contextEnd = Math.min(entry.text.length, pos + query.length + 50); const context = entry.text.slice(contextStart, contextEnd); const matchedText = entry.text.slice(pos, pos + query.length); results.push({ sessionId, projectId, sessionTitle: sessionTitle ?? 'Untitled Session', matchedText, context: (contextStart > 0 ? '...' : '') + context + (contextEnd < entry.text.length ? '...' : ''), messageType: entry.messageType, timestamp: entry.timestamp, groupId: entry.groupId, itemType: entry.itemType, matchIndexInItem: matchIndex, matchStartOffset: pos, messageUuid: entry.messageUuid, }); matchIndex++; pos += query.length; } } private async collectFulfilledInBatches( items: T[], batchSize: number, mapper: (item: T) => Promise ): Promise { const safeBatchSize = Math.max(1, batchSize); const results: R[] = []; for (let i = 0; i < items.length; i += safeBatchSize) { const batch = items.slice(i, i + safeBatchSize); const settled = await Promise.allSettled(batch.map((item) => mapper(item))); for (const result of settled) { if (result.status === 'fulfilled') { results.push(result.value); } } } return results; } private buildFastSearchStageBoundaries(totalFiles: number): number[] { if (totalFiles <= 0) { return []; } const boundaries: number[] = []; for (const limit of SSH_FAST_SEARCH_STAGE_LIMITS) { const boundary = Math.min(totalFiles, limit); if (boundaries.length === 0 || boundary > boundaries[boundaries.length - 1]) { boundaries.push(boundary); } } if (boundaries.length === 0) { boundaries.push(totalFiles); } return boundaries; } }