From 3b7a9d35c2354a8bf32cf7843917eddf679206e8 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Wed, 25 Mar 2026 10:52:44 +0300 Subject: [PATCH] refactor: move session parsing and file watcher logic to specific folders --- .../providers/claude/claude.session-parser.ts | 33 ++ .../providers/codex/codex.session-parser.ts | 34 ++ .../providers/cursor/cursor.session-parser.ts | 89 +++++ server/src/modules/providers/gemini/.gitkeep | 1 - .../providers/gemini/gemini.session-parser.ts | 36 ++ server/src/modules/providers/mcp/.gitkeep | 1 - server/src/modules/providers/plugins/.gitkeep | 1 - .../src/modules/sessions/sessions.service.ts | 32 ++ server/src/modules/sessions/sessions.types.ts | 5 + server/src/modules/sessions/sessions.utils.ts | 105 ++++++ .../sessions.watcher.ts} | 17 +- .../get-workspaces/get-workspaces.ts | 324 ------------------ server/src/runner.ts | 2 +- 13 files changed, 342 insertions(+), 338 deletions(-) create mode 100644 server/src/modules/providers/claude/claude.session-parser.ts create mode 100644 server/src/modules/providers/codex/codex.session-parser.ts create mode 100644 server/src/modules/providers/cursor/cursor.session-parser.ts delete mode 100644 server/src/modules/providers/gemini/.gitkeep create mode 100644 server/src/modules/providers/gemini/gemini.session-parser.ts delete mode 100644 server/src/modules/providers/mcp/.gitkeep delete mode 100644 server/src/modules/providers/plugins/.gitkeep create mode 100644 server/src/modules/sessions/sessions.service.ts create mode 100644 server/src/modules/sessions/sessions.types.ts create mode 100644 server/src/modules/sessions/sessions.utils.ts rename server/src/modules/{watcher/file-watcher.ts => sessions/sessions.watcher.ts} (91%) delete mode 100644 server/src/modules/workspace/get-workspaces/get-workspaces.ts diff --git a/server/src/modules/providers/claude/claude.session-parser.ts b/server/src/modules/providers/claude/claude.session-parser.ts new file mode 100644 index 00000000..6f2892cc --- /dev/null +++ b/server/src/modules/providers/claude/claude.session-parser.ts @@ -0,0 +1,33 @@ +import os from 'os'; +import path from 'path'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan, SessionData } from '@/modules/sessions/sessions.utils.js'; + +export async function processClaudeSessionFile(file: string, nameMap?: Map): Promise { + if (!nameMap) { + const base = path.join(os.homedir(), '.claude'); + nameMap = await buildLookupMap(path.join(base, 'history.jsonl'), 'sessionId', 'display'); + } + + // Claude puts cwd and sessionId directly on the root object + return extractFirstValidJsonlData(file, (data) => ({ + workspacePath: data?.cwd, + sessionId: data?.sessionId, + sessionName: nameMap!.get(data?.sessionId) || 'Untitled Claude Session' + })); +} + +export async function getClaudeSessions() { + const base = path.join(os.homedir(), '.claude'); + // Pre-load names from history index + const nameMap = await buildLookupMap(path.join(base, 'history.jsonl'), 'sessionId', 'display'); + + const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'projects'), '.jsonl'); + for (const file of files) { + const result = await processClaudeSessionFile(file, nameMap); + + if (result) { + sessionsDb.createSession(result.sessionId, 'claude', result.workspacePath, result.sessionName); + } + } +} diff --git a/server/src/modules/providers/codex/codex.session-parser.ts b/server/src/modules/providers/codex/codex.session-parser.ts new file mode 100644 index 00000000..86a1072b --- /dev/null +++ b/server/src/modules/providers/codex/codex.session-parser.ts @@ -0,0 +1,34 @@ +import os from 'os'; +import path from 'path'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan, SessionData } from '@/modules/sessions/sessions.utils.js'; + +export async function processCodexSessionFile(file: string, nameMap?: Map): Promise { + if (!nameMap) { + const base = path.join(os.homedir(), '.codex'); + nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); + } + + // Codex nests the required data inside a `payload` object + return extractFirstValidJsonlData(file, (data) => ({ + workspacePath: data?.payload?.cwd, + sessionId: data?.payload?.id, + sessionName: nameMap!.get(data?.payload?.id) || 'Untitled Codex Session' + })); +} + +export async function getCodexSessions() { + const base = path.join(os.homedir(), '.codex'); + // Use the thread_name attribute as requested + const nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); + + const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'sessions'), '.jsonl'); + + for (const file of files) { + const result = await processCodexSessionFile(file, nameMap); + + if (result) { + sessionsDb.createSession(result.sessionId, 'codex', result.workspacePath, result.sessionName); + } + } +} diff --git a/server/src/modules/providers/cursor/cursor.session-parser.ts b/server/src/modules/providers/cursor/cursor.session-parser.ts new file mode 100644 index 00000000..7cc3e558 --- /dev/null +++ b/server/src/modules/providers/cursor/cursor.session-parser.ts @@ -0,0 +1,89 @@ +import os from 'os'; +import path from 'path'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import readline from 'readline'; +import crypto from 'node:crypto'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan, SessionData } from '@/modules/sessions/sessions.utils.js'; + +function md5(input: string): string { + return crypto.createHash('md5').update(input).digest('hex'); +} + +export async function extractWorkspacePathFromWorkerLog(filePath: string): Promise { + try { + const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + + for await (const line of rl) { + const match = line.match(/workspacePath=(.*)$/); + const firstMatch = match?.[1]; + + if (firstMatch) { + rl.close(); + fileStream.close(); + return firstMatch; + } + } + } catch { + // ignore errors + } + + return null; +} + +export async function processCursorSessionFile(file: string): Promise { + const sessionId = path.basename(file, '.jsonl'); + const grandparentDir = path.dirname(path.dirname(file)); + const workerLogPath = path.join(grandparentDir, 'worker.log'); + const workspacePath = await extractWorkspacePathFromWorkerLog(workerLogPath); + + if (!workspacePath) return null; + + return extractFirstValidJsonlData(file, (lineJson) => { + if (lineJson.role === 'user') { + const rawText = lineJson.message?.content?.[0]?.text || ''; + // Strip tags and trim + const cleanName = rawText.replace(/<\/?user_query>/g, '').trim().split('\n'); + return { sessionId: sessionId as string, workspacePath, sessionName: cleanName[0] || "Untitled Cursor Session" }; + } + return null; + }); +} + +export async function getCursorSessions() { + try { + const cursorBase = path.join(os.homedir(), '.cursor'); + const projectsDir = path.join(cursorBase, 'projects'); + const projectDirs = await fsp.readdir(projectsDir); + const seenWorkspacePaths = new Set(); + + for (const projectDir of projectDirs) { + const workerLogPath = path.join(projectsDir, projectDir, 'worker.log'); + const workspacePath = await extractWorkspacePathFromWorkerLog(workerLogPath); + + if (!workspacePath || seenWorkspacePaths.has(workspacePath)) continue; + + seenWorkspacePaths.add(workspacePath); + const workspaceHash = md5(workspacePath); + const chatsDir = path.join(cursorBase, 'chats', workspaceHash); + + const sessionFiles = await findFilesRecursivelyCreatedAfterLastScan(chatsDir, '.jsonl'); + + for (const file of sessionFiles) { + const result = await processCursorSessionFile(file); + + if (result) { + sessionsDb.createSession(result.sessionId, 'cursor', result.workspacePath, result.sessionName); + } + } + } + } catch (e) { + // Base cursor directory or projects directory likely doesn't exist + } +} diff --git a/server/src/modules/providers/gemini/.gitkeep b/server/src/modules/providers/gemini/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/providers/gemini/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/providers/gemini/gemini.session-parser.ts b/server/src/modules/providers/gemini/gemini.session-parser.ts new file mode 100644 index 00000000..1d160720 --- /dev/null +++ b/server/src/modules/providers/gemini/gemini.session-parser.ts @@ -0,0 +1,36 @@ +import os from 'os'; +import path from 'path'; +import fsp from 'node:fs/promises'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { findFilesRecursivelyCreatedAfterLastScan, SessionData } from '@/modules/sessions/sessions.utils.js'; + +export async function processGeminiSessionFile(file: string): Promise { + try { + // Gemini uses standard JSON (not JSONL), so we read the whole file at once + + const fileContent = await fsp.readFile(file, 'utf8'); + const data = JSON.parse(fileContent); + if (data?.id && data?.projectPath) { + return { + sessionId: data.id, + workspacePath: data.projectPath, + sessionName: data.messages?.[0]?.content || 'New Gemini Chat' + }; + } + } catch (e) { + // Ignore parsing error for gemini + } + return null; +} + +export async function getGeminiSessions() { + const geminiPath = path.join(os.homedir(), '.gemini', 'sessions'); + const files = await findFilesRecursivelyCreatedAfterLastScan(geminiPath, '.json'); + + for (const file of files) { + const result = await processGeminiSessionFile(file); + if (result) { + sessionsDb.createSession(result.sessionId, 'gemini', result.workspacePath, result.sessionName); + } + } +} diff --git a/server/src/modules/providers/mcp/.gitkeep b/server/src/modules/providers/mcp/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/providers/mcp/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/providers/plugins/.gitkeep b/server/src/modules/providers/plugins/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/providers/plugins/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/sessions/sessions.service.ts b/server/src/modules/sessions/sessions.service.ts new file mode 100644 index 00000000..b84c926b --- /dev/null +++ b/server/src/modules/sessions/sessions.service.ts @@ -0,0 +1,32 @@ +import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js'; +import { getClaudeSessions } from '@/modules/providers/claude/claude.session-parser.js'; +import { getCodexSessions } from '@/modules/providers/codex/codex.session-parser.js'; +import { getGeminiSessions } from '@/modules/providers/gemini/gemini.session-parser.js'; +import { getCursorSessions } from '@/modules/providers/cursor/cursor.session-parser.js'; + +export async function getSessions() { + + // 1. Start the timer with a unique label + console.time("🚀 Workspace sync total time"); + + console.log("Starting workspace sync..."); + try { + // Wrapping in Promise.all allows these to process concurrently, speeding up the boot time + await Promise.allSettled([ + getClaudeSessions(), + getCodexSessions(), + getGeminiSessions(), + getCursorSessions() + ]); + + scanStateDb.updateLastScannedAt(); + } catch (error) { + console.error("An error occurred during sync:", error); + } finally { + console.log("----------------------------------"); + // 2. Stop the timer using the exact same label + // This will print: 🚀 Workspace sync total time: 123.456ms + console.timeEnd("🚀 Workspace sync total time"); + console.log("Workspace synchronization complete."); + } +} diff --git a/server/src/modules/sessions/sessions.types.ts b/server/src/modules/sessions/sessions.types.ts new file mode 100644 index 00000000..e1f7a4d3 --- /dev/null +++ b/server/src/modules/sessions/sessions.types.ts @@ -0,0 +1,5 @@ +export type SessionData = { + sessionId: string; + workspacePath: string; + sessionName?: string; +} \ No newline at end of file diff --git a/server/src/modules/sessions/sessions.utils.ts b/server/src/modules/sessions/sessions.utils.ts new file mode 100644 index 00000000..5344c197 --- /dev/null +++ b/server/src/modules/sessions/sessions.utils.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import readline from 'readline'; +import path from 'path'; +import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js'; + +// ============================================================================ +// SHARED TYPES & UTILITIES +// ============================================================================ + +export type SessionData = { + sessionId: string; + workspacePath: string; + sessionName?: string; +} + +/** + * Reads a JSONL file and builds a Map of Key -> Value. + * Useful for index files like history.jsonl or session_index.jsonl. + */ +export async function buildLookupMap(filePath: string, keyField: string, valueField: string): Promise> { + const lookup = new Map(); + try { + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (!line.trim()) continue; + const data = JSON.parse(line); + // We use the first occurrence. In history files, this is usually the start of the thread. + if (data[keyField] && data[valueField] && !lookup.has(data[keyField])) { + lookup.set(data[keyField], data[valueField]); + } + } + } catch (e) { /* File might not exist yet */ } + return lookup; +} + +/** + * Recursively walks a directory tree and returns a flat array of all files + * matching a specific extension (e.g., '.jsonl' or '.json'). + * It will only find the files created after the last scan date. + */ +export async function findFilesRecursivelyCreatedAfterLastScan( + dirPath: string, + extension: string, + fileList: string[] = [] +): Promise { + try { + const entries = await fsp.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isDirectory()) { + await findFilesRecursivelyCreatedAfterLastScan(fullPath, extension, fileList); + } else if (entry.isFile() && entry.name.endsWith(extension)) { + const lastScanDate = scanStateDb.getLastScannedAt(); + + if (lastScanDate) { + // Check file CREATION time (birthtime) against our last scan time + const stats = await fsp.stat(fullPath); + if (stats.birthtime > lastScanDate) { + fileList.push(fullPath); + } + } else { + fileList.push(fullPath); + } + } + } + } catch (e) { + // Fail silently for directories that don't exist or lack read permissions + } + return fileList; +} + +/** + * Reads a file line-by-line, parsing each line as JSON. + * It passes the parsed JSON to a custom `extractorFn`. As soon as the extractor + * successfully finds both a sessionId and workspacePath, it closes the file and returns. + */ +export async function extractFirstValidJsonlData( + filePath: string, + extractorFn: (parsedJson: any) => Partial | null | undefined +): Promise { + try { + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (!line.trim()) continue; + const parsedData = JSON.parse(line); + const extracted = extractorFn(parsedData); + + // If our custom extractor found what we need, return early + if (extracted?.sessionId && extracted?.workspacePath) { + rl.close(); + fileStream.close(); + return extracted as SessionData; + } + } + } catch (e) { + // Ignored errors + } + return null; +} diff --git a/server/src/modules/watcher/file-watcher.ts b/server/src/modules/sessions/sessions.watcher.ts similarity index 91% rename from server/src/modules/watcher/file-watcher.ts rename to server/src/modules/sessions/sessions.watcher.ts index 79c07bfd..8958fbef 100644 --- a/server/src/modules/watcher/file-watcher.ts +++ b/server/src/modules/sessions/sessions.watcher.ts @@ -3,17 +3,15 @@ import path from "path"; import os from "os"; import { promises as fsPromises } from "fs"; import { logger } from "@/shared/utils/logger.js"; -import { - processClaudeSessionFile, - processCodexSessionFile, - processGeminiSessionFile, - processCursorSessionFile, - getSessions -} from "@/modules/workspace/get-workspaces/get-workspaces.js"; +import { getSessions } from "@/modules/sessions/sessions.service.js"; +import { processClaudeSessionFile } from "@/modules/providers/claude/claude.session-parser.js"; +import { processCodexSessionFile } from "@/modules/providers/codex/codex.session-parser.js"; +import { processGeminiSessionFile } from "@/modules/providers/gemini/gemini.session-parser.js"; +import { processCursorSessionFile } from "@/modules/providers/cursor/cursor.session-parser.js"; import { sessionsDb } from "@/shared/database/repositories/sessions.db.js"; import { LLMProvider } from "@/shared/types/app.js"; -let projectsWatchers = []; +let projectsWatchers: any[] = []; // File system watchers for provider project/session folders const PROVIDER_WATCH_PATHS: { provider: LLMProvider; rootPath: string }[] = [ @@ -45,8 +43,7 @@ const WATCHER_IGNORED_PATTERNS = [ "**/.DS_Store", ]; -type EventType = "add" | "change" | "unlink" | "addDir" | "unlinkDir"; - +type EventType = "add" | "change"; const onUpdate = async ( eventType: EventType, diff --git a/server/src/modules/workspace/get-workspaces/get-workspaces.ts b/server/src/modules/workspace/get-workspaces/get-workspaces.ts deleted file mode 100644 index b050837a..00000000 --- a/server/src/modules/workspace/get-workspaces/get-workspaces.ts +++ /dev/null @@ -1,324 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fs from 'node:fs'; -import fsp from 'node:fs/promises'; -import readline from 'readline'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import crypto from 'node:crypto'; -import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js'; - -// ============================================================================ -// 1. SHARED TYPES & UTILITIES -// ============================================================================ -// By extracting file traversal and JSONL parsing, we remove 80% of the duplication. - -type SessionData = { - sessionId: string; - workspacePath: string; - sessionName?: string; -} - -/** - * Reads a JSONL file and builds a Map of Key -> Value. - * Useful for index files like history.jsonl or session_index.jsonl. - */ -export async function buildLookupMap(filePath: string, keyField: string, valueField: string): Promise> { - const lookup = new Map(); - try { - const fileStream = fs.createReadStream(filePath); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - for await (const line of rl) { - if (!line.trim()) continue; - const data = JSON.parse(line); - // We use the first occurrence. In history files, this is usually the start of the thread. - if (data[keyField] && data[valueField] && !lookup.has(data[keyField])) { - lookup.set(data[keyField], data[valueField]); - } - } - } catch (e) { /* File might not exist yet */ } - return lookup; -} - -/** - * Recursively walks a directory tree and returns a flat array of all files - * matching a specific extension (e.g., '.jsonl' or '.json'). - * It will only find the files created after - */ -async function findFilesRecursivelyCreatedAfterLastScan( - dirPath: string, - extension: string, - fileList: string[] = [] -): Promise { - try { - const entries = await fsp.readdir(dirPath, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - - if (entry.isDirectory()) { - await findFilesRecursivelyCreatedAfterLastScan(fullPath, extension, fileList); - } else if (entry.isFile() && entry.name.endsWith(extension)) { - const lastScanDate = scanStateDb.getLastScannedAt(); - - if (lastScanDate) { - // Check file CREATION time (birthtime) against our last scan time - const stats = await fsp.stat(fullPath); - if (stats.birthtime > lastScanDate) { - fileList.push(fullPath); - console.log("=====> full path is: ", fullPath) - } - } else { - fileList.push(fullPath); - } - } - } - } catch (e) { - // Fail silently for directories that don't exist or lack read permissions - } - return fileList; -} - -/** - * Reads a file line-by-line, parsing each line as JSON. - * It passes the parsed JSON to a custom `extractorFn`. As soon as the extractor - * successfully finds both a sessionId and workspacePath, it closes the file and returns. - */ -export async function extractFirstValidJsonlData( - filePath: string, - extractorFn: (parsedJson: any) => Partial | null | undefined -): Promise { - try { - const fileStream = fs.createReadStream(filePath); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - for await (const line of rl) { - if (!line.trim()) continue; - const parsedData = JSON.parse(line); - const extracted = extractorFn(parsedData); - - // If our custom extractor found what we need, return early - if (extracted?.sessionId && extracted?.workspacePath) { - rl.close(); - fileStream.close(); - return extracted as SessionData; - } - } - } catch (e) { - // Ignored errors - } - return null; -} -// ============================================================================ -// 2. JSONL-BASED PROVIDERS (Claude & Codex) -// ============================================================================ -// Now, these functions only need to define WHERE to look, and HOW to map the JSON. - -// ----- Claude ----- -export async function processClaudeSessionFile(file: string, nameMap?: Map): Promise { - if (!nameMap) { - const base = path.join(os.homedir(), '.claude'); - nameMap = await buildLookupMap(path.join(base, 'history.jsonl'), 'sessionId', 'display'); - } - - // Claude puts cwd and sessionId directly on the root object - return extractFirstValidJsonlData(file, (data) => ({ - workspacePath: data?.cwd, - sessionId: data?.sessionId, - sessionName: nameMap!.get(data?.sessionId) || 'Untitled Claude Session' - })); -} - -async function getClaudeSessions() { - const base = path.join(os.homedir(), '.claude'); - // Pre-load names from history index - const nameMap = await buildLookupMap(path.join(base, 'history.jsonl'), 'sessionId', 'display'); - - const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'projects'), '.jsonl'); - for (const file of files) { - const result = await processClaudeSessionFile(file, nameMap); - - if (result) { - sessionsDb.createSession(result.sessionId, 'claude', result.workspacePath, result.sessionName); - } - } -} - -// ----- Codex ----- -export async function processCodexSessionFile(file: string, nameMap?: Map): Promise { - if (!nameMap) { - const base = path.join(os.homedir(), '.codex'); - nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); - } - - // Codex nests the required data inside a `payload` object - return extractFirstValidJsonlData(file, (data) => ({ - workspacePath: data?.payload?.cwd, - sessionId: data?.payload?.id, - sessionName: nameMap!.get(data?.payload?.id) || 'Untitled Codex Session' - })); -} - -async function getCodexSessions() { - const base = path.join(os.homedir(), '.codex'); - // Use the thread_name attribute as requested - const nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); - - const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'sessions'), '.jsonl'); - - for (const file of files) { - const result = await processCodexSessionFile(file, nameMap); - - if (result) { - sessionsDb.createSession(result.sessionId, 'codex', result.workspacePath, result.sessionName); - } - } -} -// ============================================================================ -// 3. STANDARD JSON PROVIDERS (Gemini) -// ============================================================================ - -// ----- Gemini ----- -export async function processGeminiSessionFile(file: string): Promise { - try { - // Gemini uses standard JSON (not JSONL), so we read the whole file at once - - const fileContent = await fsp.readFile(file, 'utf8'); - const data = JSON.parse(fileContent); - if (data?.id && data?.projectPath) { - return { - sessionId: data.id, - workspacePath: data.projectPath, - sessionName: data.messages?.[0]?.content || 'New Gemini Chat' - }; - } - } catch (e) { - // Ignore parsing error for gemini - } - return null; -} - -async function getGeminiSessions() { - const geminiPath = path.join(os.homedir(), '.gemini', 'sessions'); - const files = await findFilesRecursivelyCreatedAfterLastScan(geminiPath, '.json'); - - for (const file of files) { - const result = await processGeminiSessionFile(file); - if (result) { - sessionsDb.createSession(result.sessionId, 'gemini', result.workspacePath, result.sessionName); - } - } -} - -// ============================================================================ -// 4. COMPLEX CUSTOM PROVIDERS (Cursor) -// ============================================================================ - -// ----- Cursor ----- -function md5(input: string): string { - return crypto.createHash('md5').update(input).digest('hex'); -} - -export async function extractWorkspacePathFromWorkerLog(filePath: string): Promise { - try { - const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); - - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity - }); - - for await (const line of rl) { - const match = line.match(/workspacePath=(.*)$/); - const firstMatch = match?.[1]; - - if (firstMatch) { - rl.close(); - fileStream.close(); - return firstMatch; - } - } - } catch { - // ignore errors - } - - return null; -} - -export async function processCursorSessionFile(file: string): Promise { - const sessionId = path.basename(file, '.jsonl'); - const grandparentDir = path.dirname(path.dirname(file)); - const workerLogPath = path.join(grandparentDir, 'worker.log'); - const workspacePath = await extractWorkspacePathFromWorkerLog(workerLogPath); - - if (!workspacePath) return null; - - return extractFirstValidJsonlData(file, (lineJson) => { - if (lineJson.role === 'user') { - const rawText = lineJson.message?.content?.[0]?.text || ''; - // Strip tags and trim - const cleanName = rawText.replace(/<\/?user_query>/g, '').trim().split('\n'); - return { sessionId: sessionId as string, workspacePath, sessionName: cleanName[0] || "Untitled Cursor Session" }; - } - return null; - }); -} - -async function getCursorSessions() { - try { - const cursorBase = path.join(os.homedir(), '.cursor'); - const projectsDir = path.join(cursorBase, 'projects'); - const projectDirs = await fsp.readdir(projectsDir); - const seenWorkspacePaths = new Set(); - - for (const projectDir of projectDirs) { - const workerLogPath = path.join(projectsDir, projectDir, 'worker.log'); - const workspacePath = await extractWorkspacePathFromWorkerLog(workerLogPath); - - if (!workspacePath || seenWorkspacePaths.has(workspacePath)) continue; - - seenWorkspacePaths.add(workspacePath); - const workspaceHash = md5(workspacePath); - const chatsDir = path.join(cursorBase, 'chats', workspaceHash); - - const sessionFiles = await findFilesRecursivelyCreatedAfterLastScan(chatsDir, '.jsonl'); - - for (const file of sessionFiles) { - const result = await processCursorSessionFile(file); - - if (result) { - sessionsDb.createSession(result.sessionId, 'cursor', result.workspacePath, result.sessionName); - } - } - } - } catch (e) { - // Base cursor directory or projects directory likely doesn't exist - } -} - - -export async function getSessions() { - - // 1. Start the timer with a unique label - console.time("🚀 Workspace sync total time"); - - console.log("Starting workspace sync..."); - try { - // Wrapping in Promise.all allows these to process concurrently, speeding up the boot time - await Promise.allSettled([ - getClaudeSessions(), - getCodexSessions(), - getGeminiSessions(), - getCursorSessions() - ]); - - scanStateDb.updateLastScannedAt(); - } catch (error) { - console.error("An error occurred during sync:", error); - } finally { - console.log("----------------------------------"); - // 2. Stop the timer using the exact same label - // This will print: 🚀 Workspace sync total time: 123.456ms - console.timeEnd("🚀 Workspace sync total time"); - console.log("Workspace synchronization complete."); - } -} diff --git a/server/src/runner.ts b/server/src/runner.ts index 0e44a2aa..cc472545 100644 --- a/server/src/runner.ts +++ b/server/src/runner.ts @@ -3,7 +3,7 @@ import http from 'http'; import { userDb } from "@/shared/database/repositories/users.js"; import { initializeDatabase } from '@/shared/database/init-db.js'; -import { initializeWatcher } from '@/modules/watcher/file-watcher.js'; +import { initializeWatcher } from '@/modules/sessions/sessions.watcher.js'; console.log("----------------Hello there, Refactored Runner!-------------------");