agent-ecosystem/src/main/services/discovery/SessionSearcher.ts
2026-04-28 16:08:05 +03:00

345 lines
11 KiB
TypeScript

/**
* 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<SearchSessionsResult> {
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<SearchResult[]> {
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<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;
}
}