diff --git a/package.json b/package.json index a99f83e8..e905dcf5 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "date-fns": "^3.6.0", "electron-updater": "^6.7.3", "fastify": "^5.7.4", + "fuse.js": "^7.4.2", "idb-keyval": "^6.2.2", "lucide-react": "^0.562.0", "mdast-util-to-hast": "^13.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d4d1e8c..b2b33ebe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: fastify: specifier: ^5.7.4 version: 5.8.5 + fuse.js: + specifier: ^7.4.2 + version: 7.4.2 idb-keyval: specifier: ^6.2.2 version: 6.2.2 @@ -3028,6 +3031,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.4.2: + resolution: {integrity: sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==} + engines: {node: '>=10'} + gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -8487,6 +8494,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.4.2: {} + gauge@4.0.4: dependencies: aproba: 2.1.0 diff --git a/src/main/http/search.ts b/src/main/http/search.ts index 39a36c5c..6178dad8 100644 --- a/src/main/http/search.ts +++ b/src/main/http/search.ts @@ -23,7 +23,7 @@ const logger = createLogger('HTTP:search'); export function registerSearchRoutes(app: FastifyInstance, services: HttpServices): void { app.get<{ Params: { projectId: string }; - Querystring: { q?: string; maxResults?: string }; + Querystring: { q?: string; maxResults?: string; fuzzy?: string }; }>('/api/projects/:projectId/search', async (request) => { const query = request.query.q ?? ''; @@ -45,7 +45,8 @@ export function registerSearchRoutes(app: FastifyInstance, services: HttpService const result = await services.projectScanner.searchSessions( validatedProject.value!, validatedQuery.value!, - maxResults + maxResults, + request.query.fuzzy === '1' || request.query.fuzzy === 'true' ); return result; } catch (error) { @@ -55,7 +56,7 @@ export function registerSearchRoutes(app: FastifyInstance, services: HttpService }); app.get<{ - Querystring: { q?: string; maxResults?: string }; + Querystring: { q?: string; maxResults?: string; fuzzy?: string }; }>('/api/search', async (request) => { const query = request.query.q ?? ''; @@ -73,7 +74,8 @@ export function registerSearchRoutes(app: FastifyInstance, services: HttpService const result = await services.projectScanner.searchAllProjects( validatedQuery.value!, - maxResults + maxResults, + request.query.fuzzy === '1' || request.query.fuzzy === 'true' ); return result; } catch (error) { diff --git a/src/main/ipc/search.ts b/src/main/ipc/search.ts index de5c5414..57b28c64 100644 --- a/src/main/ipc/search.ts +++ b/src/main/ipc/search.ts @@ -72,7 +72,8 @@ async function handleSearchSessions( _event: IpcMainInvokeEvent, projectId: string, query: string, - maxResults?: number + maxResults?: number, + fuzzy?: boolean ): Promise { try { const validatedProject = validateProjectId(projectId); @@ -89,7 +90,8 @@ async function handleSearchSessions( const result = await projectScanner.searchSessions( validatedProject.value!, validatedQuery.value!, - safeMaxResults + safeMaxResults, + fuzzy === true ); return result; } catch (error) { @@ -105,7 +107,8 @@ async function handleSearchSessions( async function handleSearchAllProjects( _event: IpcMainInvokeEvent, query: string, - maxResults?: number + maxResults?: number, + fuzzy?: boolean ): Promise { try { const validatedQuery = validateSearchQuery(query); @@ -116,7 +119,11 @@ async function handleSearchAllProjects( const { projectScanner } = registry.getActive(); const safeMaxResults = coerceSearchMaxResults(maxResults, 50); - const result = await projectScanner.searchAllProjects(validatedQuery.value!, safeMaxResults); + const result = await projectScanner.searchAllProjects( + validatedQuery.value!, + safeMaxResults, + fuzzy === true + ); return result; } catch (error) { logger.error('Error in search-all-projects:', error); diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 32e4b551..073805b7 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -1096,9 +1096,10 @@ export class ProjectScanner { async searchSessions( projectId: string, query: string, - maxResults: number = 50 + maxResults: number = 50, + fuzzy: boolean = false ): Promise { - return this.sessionSearcher.searchSessions(projectId, query, maxResults); + return this.sessionSearcher.searchSessions(projectId, query, maxResults, fuzzy); } /** @@ -1108,7 +1109,11 @@ export class ProjectScanner { * @param query - Search query string * @param maxResults - Maximum number of results to return (default 50) */ - async searchAllProjects(query: string, maxResults: number = 50): Promise { + async searchAllProjects( + query: string, + maxResults: number = 50, + fuzzy: boolean = false + ): Promise { const startedAt = Date.now(); try { if (!query || query.trim().length === 0) { @@ -1138,7 +1143,9 @@ export class ProjectScanner { for (let i = 0; i < projects.length; i += searchBatchSize) { const batch = projects.slice(i, i + searchBatchSize); const batchResults = await Promise.allSettled( - batch.map((project) => this.sessionSearcher.searchSessions(project.id, query, maxResults)) + batch.map((project) => + this.sessionSearcher.searchSessions(project.id, query, maxResults, fuzzy) + ) ); for (const result of batchResults) { @@ -1147,9 +1154,7 @@ export class ProjectScanner { } } - // Check if we have enough results already - const totalMatches = allResults.reduce((sum, r) => sum + r.totalMatches, 0); - if (totalMatches >= maxResults) { + if (!fuzzy && allResults.reduce((sum, r) => sum + r.totalMatches, 0) >= maxResults) { break; } } @@ -1158,8 +1163,11 @@ export class ProjectScanner { const mergedResults = allResults.flatMap((r) => r.results); const totalSessionsSearched = allResults.reduce((sum, r) => sum + r.sessionsSearched, 0); - // Sort by timestamp (most recent first) and limit to maxResults - mergedResults.sort((a, b) => b.timestamp - a.timestamp); + mergedResults.sort((a, b) => + fuzzy + ? (a.matchScore ?? 1) - (b.matchScore ?? 1) || b.timestamp - a.timestamp + : b.timestamp - a.timestamp + ); const limitedResults = mergedResults.slice(0, maxResults); logger.debug( diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index c3d26ea0..480b174b 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -15,6 +15,7 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile import { parseJsonlFile } from '@main/utils/jsonl'; import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import Fuse from 'fuse.js'; import * as path from 'path'; import { SearchTextCache } from './SearchTextCache'; @@ -30,6 +31,10 @@ 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; +// Fuzzy search tuning (opt-in). Fuse threshold: 0 = exact, 1 = match anything. +const FUZZY_THRESHOLD = 0.4; +const FUZZY_MIN_MATCH_CHAR_LENGTH = 2; + /** * SessionSearcher provides methods for searching sessions. */ @@ -37,6 +42,7 @@ export class SessionSearcher { private readonly projectsDir: string; private readonly fsProvider: FileSystemProvider; private readonly searchCache: SearchTextCache; + private readonly fuzzyIndexes = new WeakMap>(); constructor(projectsDir: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir; @@ -56,7 +62,8 @@ export class SessionSearcher { async searchSessions( projectId: string, query: string, - maxResults: number = 50 + maxResults: number = 50, + fuzzy: boolean = false ): Promise { const startedAt = Date.now(); const results: SearchResult[] = []; @@ -115,7 +122,7 @@ export class SessionSearcher { for (const stageBoundary of stageBoundaries) { for ( let i = searchedUntil; - i < stageBoundary && results.length < maxResults; + i < stageBoundary && (fuzzy || results.length < maxResults); i += searchBatchSize ) { if (fastMode && Date.now() - startedAt >= SSH_FAST_SEARCH_TIME_BUDGET_MS) { @@ -136,27 +143,32 @@ export class SessionSearcher { file.filePath, normalizedQuery, maxResults, - file.mtimeMs + file.mtimeMs, + fuzzy ); }) ); for (const result of settled) { - if (results.length >= maxResults) { + if (!fuzzy && 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)); + if (fuzzy) { + results.push(...result.value); + } else { + const remaining = maxResults - results.length; + results.push(...result.value.slice(0, remaining)); + } } } searchedUntil = stageBoundary; - if (shouldStop || !fastMode || results.length >= maxResults) { + if (shouldStop || !fastMode || (!fuzzy && results.length >= maxResults)) { break; } @@ -166,19 +178,30 @@ export class SessionSearcher { } } - if (fastMode && results.length < maxResults && sessionsSearched < sessionFiles.length) { + if ( + fastMode && + sessionsSearched < sessionFiles.length && + (fuzzy || results.length < maxResults) + ) { isPartial = true; } + if (fuzzy) { + results.sort( + (a, b) => (a.matchScore ?? 1) - (b.matchScore ?? 1) || b.timestamp - a.timestamp + ); + } + const limitedResults = fuzzy ? results.slice(0, maxResults) : results; + if (fastMode) { logger.debug( - `SSH fast search scanned ${sessionsSearched}/${sessionFiles.length} sessions in ${Date.now() - startedAt}ms (results=${results.length}, partial=${isPartial})` + `SSH fast search scanned ${sessionsSearched}/${sessionFiles.length} sessions in ${Date.now() - startedAt}ms (results=${limitedResults.length}, partial=${isPartial})` ); } return { - results, - totalMatches: results.length, + results: limitedResults, + totalMatches: limitedResults.length, sessionsSearched, query, isPartial: fastMode ? isPartial : undefined, @@ -209,7 +232,8 @@ export class SessionSearcher { filePath: string, query: string, maxResults: number, - mtimeMs: number + mtimeMs: number, + fuzzy: boolean = false ): Promise { const results: SearchResult[] = []; @@ -225,6 +249,17 @@ export class SessionSearcher { const { entries, sessionTitle } = cached; + if (fuzzy) { + return this.collectFuzzyMatches( + entries, + query, + maxResults, + projectId, + sessionId, + sessionTitle + ); + } + // 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; @@ -291,6 +326,68 @@ export class SessionSearcher { } } + /** + * Fuzzy variant of entry matching. Builds a Fuse index over the session's + * searchable entries so an approximate (typo-tolerant) query still surfaces + * the session, ranked by Fuse score. Snippets are derived from the matched + * character ranges Fuse reports. + */ + private collectFuzzyMatches( + entries: SearchableEntry[], + query: string, + maxResults: number, + projectId: string, + sessionId: string, + sessionTitle?: string + ): SearchResult[] { + if (entries.length === 0) return []; + + let fuse = this.fuzzyIndexes.get(entries); + if (!fuse) { + fuse = new Fuse(entries, { + keys: ['text'], + includeMatches: true, + includeScore: true, + ignoreLocation: true, + threshold: FUZZY_THRESHOLD, + minMatchCharLength: FUZZY_MIN_MATCH_CHAR_LENGTH, + }); + this.fuzzyIndexes.set(entries, fuse); + } + + const results: SearchResult[] = []; + for (const hit of fuse.search(query, { limit: maxResults })) { + if (results.length >= maxResults) break; + + const entry = hit.item; + const textMatch = hit.matches?.find((match) => match.key === 'text'); + const { start, end } = resolveFuzzyMatchSpan(textMatch?.indices, query, entry.text.length); + + const contextStart = Math.max(0, start - 50); + const contextEnd = Math.min(entry.text.length, end + 50); + const context = entry.text.slice(contextStart, contextEnd); + + results.push({ + sessionId, + projectId, + sessionTitle: sessionTitle ?? 'Untitled Session', + matchedText: entry.text.slice(start, end), + context: + (contextStart > 0 ? '...' : '') + context + (contextEnd < entry.text.length ? '...' : ''), + messageType: entry.messageType, + timestamp: entry.timestamp, + groupId: entry.groupId, + itemType: entry.itemType, + matchIndexInItem: 0, + matchStartOffset: start, + messageUuid: entry.messageUuid, + matchScore: hit.score ?? 1, + }); + } + + return results; + } + private async collectFulfilledInBatches( items: T[], batchSize: number, @@ -332,3 +429,19 @@ export class SessionSearcher { return boundaries; } } + +/** + * Returns the first Fuse match as a [start, end) span. + */ +function resolveFuzzyMatchSpan( + indices: readonly (readonly [number, number])[] | undefined, + query: string, + textLength: number +): { start: number; end: number } { + if (!indices || indices.length === 0) { + return { start: 0, end: Math.min(query.length, textLength) }; + } + + const [start, end] = indices[0]; + return { start: Math.max(0, start), end: Math.min(textLength, end + 1) }; +} diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index c50f1a44..6ce52174 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -245,6 +245,8 @@ export interface SearchResult { matchStartOffset?: number; /** Source message UUID for diagnostics/fallback mapping */ messageUuid?: string; + /** Fuse score when fuzzy search is enabled */ + matchScore?: number; } /** diff --git a/src/preload/index.ts b/src/preload/index.ts index 0d162235..19d9c1f4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -149,10 +149,10 @@ const electronAPI: ElectronAPI = { limit?: number, options?: SessionsPaginationOptions ) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options), - searchSessions: (projectId: string, query: string, maxResults?: number) => - ipcRenderer.invoke('search-sessions', projectId, query, maxResults), - searchAllProjects: (query: string, maxResults?: number) => - ipcRenderer.invoke('search-all-projects', query, maxResults), + searchSessions: (projectId: string, query: string, maxResults?: number, fuzzy?: boolean) => + ipcRenderer.invoke('search-sessions', projectId, query, maxResults, fuzzy), + searchAllProjects: (query: string, maxResults?: number, fuzzy?: boolean) => + ipcRenderer.invoke('search-all-projects', query, maxResults, fuzzy), findSessionById: (sessionId: string) => ipcRenderer.invoke(FIND_SESSION_BY_ID, sessionId), findSessionsByPartialId: (fragment: string) => ipcRenderer.invoke(FIND_SESSIONS_BY_PARTIAL_ID, fragment), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 74a258a0..d6d88b59 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -213,18 +213,25 @@ export class HttpAPIClient implements ElectronAPI { searchSessions = ( projectId: string, query: string, - maxResults?: number + maxResults?: number, + fuzzy?: boolean ): Promise => { const params = new URLSearchParams({ q: query }); if (maxResults) params.set('maxResults', String(maxResults)); + if (fuzzy) params.set('fuzzy', '1'); return this.get( `/api/projects/${encodeURIComponent(projectId)}/search?${params}` ); }; - searchAllProjects = (query: string, maxResults?: number): Promise => { + searchAllProjects = ( + query: string, + maxResults?: number, + fuzzy?: boolean + ): Promise => { const params = new URLSearchParams({ q: query }); if (maxResults) params.set('maxResults', String(maxResults)); + if (fuzzy) params.set('fuzzy', '1'); return this.get(`/api/search?${params}`); }; diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 3d2fd320..473332e5 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -23,6 +23,7 @@ import { Loader2, MessageSquare, Search, + Sparkles, User, X, } from 'lucide-react'; @@ -243,6 +244,7 @@ export const CommandPalette = (): React.JSX.Element | null => { const [totalMatches, setTotalMatches] = useState(0); const [searchIsPartial, setSearchIsPartial] = useState(false); const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false); + const [fuzzyEnabled, setFuzzyEnabled] = useState(false); const [sessionIdMatch, setSessionIdMatch] = useState(null); const [partialIdMatches, setPartialIdMatches] = useState< FindSessionsByPartialIdResult['results'] @@ -331,6 +333,7 @@ export const CommandPalette = (): React.JSX.Element | null => { setTotalMatches(0); setSearchIsPartial(false); setGlobalSearchEnabled(false); + setFuzzyEnabled(false); setSessionIdMatch(null); setPartialIdMatches([]); } @@ -428,8 +431,8 @@ export const CommandPalette = (): React.JSX.Element | null => { setLoading(true); try { const searchResult = globalSearchEnabled - ? await api.searchAllProjects(query.trim(), 50) - : await api.searchSessions(selectedProjectId!, query.trim(), 50); + ? await api.searchAllProjects(query.trim(), 50, fuzzyEnabled) + : await api.searchSessions(selectedProjectId!, query.trim(), 50, fuzzyEnabled); if (latestSearchRequestRef.current !== requestId) { return; } @@ -459,6 +462,7 @@ export const CommandPalette = (): React.JSX.Element | null => { commandPaletteOpen, searchMode, globalSearchEnabled, + fuzzyEnabled, queryIsSessionId, ]); @@ -668,22 +672,36 @@ export const CommandPalette = (): React.JSX.Element | null => { )} - +
+ + +
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index ddeac691..c87f6674 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -362,9 +362,14 @@ export interface ElectronAPI { searchSessions: ( projectId: string, query: string, - maxResults?: number + maxResults?: number, + fuzzy?: boolean + ) => Promise; + searchAllProjects: ( + query: string, + maxResults?: number, + fuzzy?: boolean ) => Promise; - searchAllProjects: (query: string, maxResults?: number) => Promise; findSessionById: (sessionId: string) => Promise; findSessionsByPartialId: (fragment: string) => Promise; /** diff --git a/test/main/ipc/globalSearch.test.ts b/test/main/ipc/globalSearch.test.ts index a25da8e9..76cf80d0 100644 --- a/test/main/ipc/globalSearch.test.ts +++ b/test/main/ipc/globalSearch.test.ts @@ -133,8 +133,8 @@ describe('Global Search - ProjectScanner.searchAllProjects', () => { expect(mockScan).toHaveBeenCalledOnce(); expect(mockSearchSessions).toHaveBeenCalledTimes(2); - expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 50); - expect(mockSearchSessions).toHaveBeenCalledWith('project2', 'test', 50); + expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 50, false); + expect(mockSearchSessions).toHaveBeenCalledWith('project2', 'test', 50, false); expect(result.results).toHaveLength(2); expect(result.totalMatches).toBe(2); @@ -145,6 +145,45 @@ describe('Global Search - ProjectScanner.searchAllProjects', () => { expect(result.results[1].projectId).toBe('project2'); }); + it('should rank fuzzy results across every project before limiting', async () => { + const now = Date.now(); + const mockProjects: Project[] = Array.from({ length: 9 }, (_, i) => ({ + id: `project${i}`, + path: `/path/to/project${i}`, + name: `Project ${i}`, + sessions: [`session${i}`], + createdAt: now - i, + })); + mockScan.mockResolvedValue(mockProjects); + mockSearchSessions.mockImplementation((projectId: string) => { + const isBestMatch = projectId === 'project8'; + return Promise.resolve({ + results: [ + { + projectId, + sessionId: projectId, + sessionTitle: projectId, + context: 'authentication', + matchedText: 'authentication', + messageType: 'user' as const, + timestamp: isBestMatch ? now - 10_000 : now, + matchScore: isBestMatch ? 0.01 : 0.5, + }, + ], + totalMatches: 1, + sessionsSearched: 1, + query: 'authentication', + } satisfies SearchSessionsResult); + }); + + const result = await projectScanner.searchAllProjects('authentication', 1, true); + + expect(mockSearchSessions).toHaveBeenCalledTimes(9); + expect(mockSearchSessions).toHaveBeenCalledWith('project8', 'authentication', 1, true); + expect(result.results).toHaveLength(1); + expect(result.results[0].projectId).toBe('project8'); + }); + it('should sort results by timestamp (most recent first)', async () => { const now = Date.now(); @@ -260,7 +299,7 @@ describe('Global Search - ProjectScanner.searchAllProjects', () => { const result = await projectScanner.searchAllProjects('test', 25); expect(result.results.length).toBe(25); // Limited to maxResults - expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 25); + expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 25, false); }); it('should handle search errors gracefully', async () => { diff --git a/test/main/services/discovery/SessionSearcher.test.ts b/test/main/services/discovery/SessionSearcher.test.ts index 385fa83c..021c7b84 100644 --- a/test/main/services/discovery/SessionSearcher.test.ts +++ b/test/main/services/discovery/SessionSearcher.test.ts @@ -117,4 +117,116 @@ describe('SessionSearcher', () => { expect(userResults).toHaveLength(1); expect(aiResults).toHaveLength(1); }); + + it('matches approximate (typo) queries only when fuzzy is enabled', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-fuzzy-')); + tempDirs.push(projectsDir); + + const projectId = 'project-fuzzy'; + const sessionId = 'session-fuzzy'; + const projectPath = path.join(projectsDir, projectId); + fs.mkdirSync(projectPath, { recursive: true }); + + const sessionPath = path.join(projectPath, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ + uuid: 'user-fuzzy-1', + type: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'How does the authentication middleware work?' }, + isMeta: false, + }), + ]; + fs.writeFileSync(sessionPath, `${lines.join('\n')}\n`, 'utf8'); + + const searcher = new SessionSearcher(projectsDir); + + // Exact search (default) does not tolerate the dropped letter in "authentcation". + const exact = await searcher.searchSessions(projectId, 'authentcation', 50); + expect(exact.totalMatches).toBe(0); + + // Fuzzy search tolerates the typo and surfaces the session. + const fuzzy = await searcher.searchSessions(projectId, 'authentcation', 50, true); + expect(fuzzy.totalMatches).toBeGreaterThan(0); + expect(fuzzy.results[0].sessionId).toBe(sessionId); + expect(fuzzy.results[0].matchedText.toLowerCase()).toContain('authent'); + }); + + it('keeps exact substring results intact when fuzzy is enabled', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-fuzzy-exact-')); + tempDirs.push(projectsDir); + + const projectId = 'project-fuzzy-exact'; + const sessionId = 'session-fuzzy-exact'; + const projectPath = path.join(projectsDir, projectId); + fs.mkdirSync(projectPath, { recursive: true }); + + const sessionPath = path.join(projectPath, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ + uuid: 'user-fe-1', + type: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'deploy the gateway service' }, + isMeta: false, + }), + ]; + fs.writeFileSync(sessionPath, `${lines.join('\n')}\n`, 'utf8'); + + const searcher = new SessionSearcher(projectsDir); + const fuzzy = await searcher.searchSessions(projectId, 'gateway', 50, true); + + expect(fuzzy.totalMatches).toBeGreaterThan(0); + expect(fuzzy.results[0].matchedText.toLowerCase()).toContain('gateway'); + }); + + it('ranks stronger fuzzy matches across sessions before limiting', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-ranked-')); + tempDirs.push(projectsDir); + + const projectId = 'project-ranked'; + const projectPath = path.join(projectsDir, projectId); + fs.mkdirSync(projectPath, { recursive: true }); + + const olderPath = path.join(projectPath, 'older.jsonl'); + fs.writeFileSync( + olderPath, + `${JSON.stringify({ + uuid: 'older', + type: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'How does the authentication middleware work?' }, + isMeta: false, + })}\n`, + 'utf8' + ); + + const newerPath = path.join(projectPath, 'newer.jsonl'); + fs.writeFileSync( + newerPath, + `${JSON.stringify({ + uuid: 'newer', + type: 'user', + timestamp: '2026-01-02T00:00:00.000Z', + message: { role: 'user', content: 'How does the authentcation middleware work?' }, + isMeta: false, + })}\n`, + 'utf8' + ); + + const now = Date.now(); + fs.utimesSync(olderPath, new Date(now - 60_000), new Date(now - 60_000)); + fs.utimesSync(newerPath, new Date(now), new Date(now)); + + const result = await new SessionSearcher(projectsDir).searchSessions( + projectId, + 'authentication', + 1, + true + ); + + expect(result.sessionsSearched).toBe(2); + expect(result.results).toHaveLength(1); + expect(result.results[0].sessionId).toBe('older'); + }); });