408 lines · 12.6 KB · TypeScript
RawDownload
/** * 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 */ import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder';import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';import {  isEnhancedAIChunk,  isUserChunk,  type ParsedMessage,  type SearchResult,  type SearchSessionsResult,  type SemanticStep,} from '@main/types';import { parseJsonlFile } from '@main/utils/jsonl';import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder';import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';import { createLogger } from '@shared/utils/logger';import {  extractMarkdownPlainText,  findMarkdownSearchMatches,} from '@shared/utils/markdownTextSearch';import * as path from 'path'; import { subprojectRegistry } from './SubprojectRegistry'; import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; 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; interface SearchableEntry {  text: string;  groupId: string;  messageType: 'user' | 'assistant';  itemType: 'user' | 'ai';  timestamp: number;  messageUuid: string;} /** * SessionSearcher provides methods for searching sessions. */export class SessionSearcher {  private readonly projectsDir: string;  private readonly chunkBuilder: ChunkBuilder;  private readonly fsProvider: FileSystemProvider;   constructor(projectsDir: string, fsProvider?: FileSystemProvider) {    this.projectsDir = projectsDir;    this.chunkBuilder = new ChunkBuilder();    this.fsProvider = fsProvider ?? new LocalFileSystemProvider();  }   /**   * 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<SearchSessionsResult> {    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 baseDir = extractBaseDir(projectId);      const projectPath = path.join(this.projectsDir, baseDir);      const sessionFilter = subprojectRegistry.getSessionFilter(projectId);       if (!(await this.fsProvider.exists(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 : 8;      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              );            })          );           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 };    }  }   /**   * Searches a single session file for a query string.   *   * @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   * @returns Array of search results   */  async searchSessionFile(    projectId: string,    sessionId: string,    filePath: string,    query: string,    maxResults: number  ): Promise<SearchResult[]> {    const results: SearchResult[] = [];    let sessionTitle: string | undefined;    const messages = await parseJsonlFile(filePath, this.fsProvider);    const chunks = this.chunkBuilder.buildChunks(messages, []);     for (const chunk of chunks) {      if (results.length >= maxResults) {        break;      }       if (isUserChunk(chunk)) {        const userText = this.extractUserSearchableText(chunk.userMessage);        if (!sessionTitle && userText) {          sessionTitle = userText.slice(0, 100);        }        if (!userText) {          continue;        }        const searchableEntry: SearchableEntry = {          text: userText,          groupId: chunk.id,          messageType: 'user',          itemType: 'user',          timestamp: chunk.userMessage.timestamp.getTime(),          messageUuid: chunk.userMessage.uuid,        };        this.collectMatchesForEntry(          searchableEntry,          query,          results,          maxResults,          projectId,          sessionId,          sessionTitle        );        continue;      }       if (isEnhancedAIChunk(chunk)) {        const lastOutputStep = this.findLastOutputTextStep(chunk.semanticSteps);        const outputText = lastOutputStep?.content.outputText;        if (!lastOutputStep || !outputText) {          continue;        }         const searchableEntry: SearchableEntry = {          text: outputText,          groupId: chunk.id,          messageType: 'assistant',          itemType: 'ai',          timestamp: lastOutputStep.startTime.getTime(),          messageUuid: lastOutputStep.sourceMessageId ?? chunk.responses[0]?.uuid ?? '',        };        this.collectMatchesForEntry(          searchableEntry,          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 {    const mdMatches = findMarkdownSearchMatches(entry.text, query);    if (mdMatches.length === 0) return;     // Build plain text once for context snippet extraction    const plainText = extractMarkdownPlainText(entry.text);    const lowerPlain = plainText.toLowerCase();     for (const mdMatch of mdMatches) {      if (results.length >= maxResults) return;       // Find approximate position in plain text for context extraction      let pos = 0;      for (let i = 0; i < mdMatch.matchIndexInItem; i++) {        const idx = lowerPlain.indexOf(query, pos);        if (idx === -1) break;        pos = idx + query.length;      }      const matchPos = lowerPlain.indexOf(query, pos);      const effectivePos = matchPos >= 0 ? matchPos : 0;       const contextStart = Math.max(0, effectivePos - 50);      const contextEnd = Math.min(plainText.length, effectivePos + query.length + 50);      const context = plainText.slice(contextStart, contextEnd);      const matchedText =        matchPos >= 0 ? plainText.slice(matchPos, matchPos + query.length) : query;       results.push({        sessionId,        projectId,        sessionTitle: sessionTitle ?? 'Untitled Session',        matchedText,        context:          (contextStart > 0 ? '...' : '') + context + (contextEnd < plainText.length ? '...' : ''),        messageType: entry.messageType,        timestamp: entry.timestamp,        groupId: entry.groupId,        itemType: entry.itemType,        matchIndexInItem: mdMatch.matchIndexInItem,        matchStartOffset: effectivePos,        messageUuid: entry.messageUuid,      });    }  }   private extractUserSearchableText(message: ParsedMessage): string {    let rawText = '';    if (typeof message.content === 'string') {      rawText = message.content;    } else if (Array.isArray(message.content)) {      rawText = message.content        .filter((block) => block.type === 'text')        .map((block) => block.text)        .join('');    }    return sanitizeDisplayContent(rawText);  }   private findLastOutputTextStep(steps: SemanticStep[]): SemanticStep | null {    for (let i = steps.length - 1; i >= 0; i--) {      const step = steps[i];      if (step.type === 'output' && step.content.outputText) {        return step;      }    }    return null;  }   private async collectFulfilledInBatches<T, R>(    items: T[],    batchSize: number,    mapper: (item: T) => Promise<R>  ): Promise<R[]> {    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;  }}