mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-10 14:59:46 +00:00
1063 lines
35 KiB
JavaScript
Executable File
1063 lines
35 KiB
JavaScript
Executable File
/**
|
|
* PROJECT DISCOVERY AND MANAGEMENT SYSTEM
|
|
* ========================================
|
|
*
|
|
* This module manages project discovery for both Claude CLI and Cursor CLI sessions.
|
|
*
|
|
* ## Architecture Overview
|
|
*
|
|
* 1. **Claude Projects** (stored in ~/.claude/projects/)
|
|
* - Each project is a directory named with the project path encoded (/ replaced with -)
|
|
* - Contains .jsonl files with conversation history including 'cwd' field
|
|
* - Project metadata stored in ~/.claude/project-config.json
|
|
*
|
|
* 2. **Cursor Projects** (stored in ~/.cursor/chats/)
|
|
* - Each project directory is named with MD5 hash of the absolute project path
|
|
* - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6...
|
|
* - Contains session directories with SQLite databases (store.db)
|
|
* - Project path is NOT stored in the database - only in the MD5 hash
|
|
*
|
|
* ## Project Discovery Strategy
|
|
*
|
|
* 1. **Claude Projects Discovery**:
|
|
* - Scan ~/.claude/projects/ directory for Claude project folders
|
|
* - Extract actual project path from .jsonl files (cwd field)
|
|
* - Fall back to decoded directory name if no sessions exist
|
|
*
|
|
* 2. **Cursor Sessions Discovery**:
|
|
* - For each KNOWN project (from Claude or manually added)
|
|
* - Compute MD5 hash of the project's absolute path
|
|
* - Check if ~/.cursor/chats/{md5_hash}/ directory exists
|
|
* - Read session metadata from SQLite store.db files
|
|
*
|
|
* 3. **Manual Project Addition**:
|
|
* - Users can manually add project paths via UI
|
|
* - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag
|
|
* - Allows discovering Cursor sessions for projects without Claude sessions
|
|
*
|
|
* ## Critical Limitations
|
|
*
|
|
* - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of
|
|
* the cwd of each project. if someone has the time, you can try to reverse engineer it.
|
|
*
|
|
* - **Project relocation breaks history**: If a project directory is moved or renamed,
|
|
* the MD5 hash changes, making old Cursor sessions inaccessible unless the old
|
|
* path is known and manually added.
|
|
*
|
|
* ## Error Handling
|
|
*
|
|
* - Missing ~/.claude directory is handled gracefully with automatic creation
|
|
* - ENOENT errors are caught and handled without crashing
|
|
* - Empty arrays returned when no projects/sessions exist
|
|
*
|
|
* ## Caching Strategy
|
|
*
|
|
* - Project directory extraction is cached to minimize file I/O
|
|
* - Cache is cleared when project configuration changes
|
|
* - Session data is fetched on-demand, not cached
|
|
*/
|
|
|
|
import { promises as fs } from 'fs';
|
|
import fsSync from 'fs';
|
|
import path from 'path';
|
|
import readline from 'readline';
|
|
import crypto from 'crypto';
|
|
import sqlite3 from 'sqlite3';
|
|
import { open } from 'sqlite';
|
|
import os from 'os';
|
|
|
|
// Import TaskMaster detection functions
|
|
async function detectTaskMasterFolder(projectPath) {
|
|
try {
|
|
const taskMasterPath = path.join(projectPath, '.taskmaster');
|
|
|
|
// Check if .taskmaster directory exists
|
|
try {
|
|
const stats = await fs.stat(taskMasterPath);
|
|
if (!stats.isDirectory()) {
|
|
return {
|
|
hasTaskmaster: false,
|
|
reason: '.taskmaster exists but is not a directory'
|
|
};
|
|
}
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return {
|
|
hasTaskmaster: false,
|
|
reason: '.taskmaster directory not found'
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// Check for key TaskMaster files
|
|
const keyFiles = [
|
|
'tasks/tasks.json',
|
|
'config.json'
|
|
];
|
|
|
|
const fileStatus = {};
|
|
let hasEssentialFiles = true;
|
|
|
|
for (const file of keyFiles) {
|
|
const filePath = path.join(taskMasterPath, file);
|
|
try {
|
|
await fs.access(filePath);
|
|
fileStatus[file] = true;
|
|
} catch (error) {
|
|
fileStatus[file] = false;
|
|
if (file === 'tasks/tasks.json') {
|
|
hasEssentialFiles = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse tasks.json if it exists for metadata
|
|
let taskMetadata = null;
|
|
if (fileStatus['tasks/tasks.json']) {
|
|
try {
|
|
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
|
|
const tasksContent = await fs.readFile(tasksPath, 'utf8');
|
|
const tasksData = JSON.parse(tasksContent);
|
|
|
|
// Handle both tagged and legacy formats
|
|
let tasks = [];
|
|
if (tasksData.tasks) {
|
|
// Legacy format
|
|
tasks = tasksData.tasks;
|
|
} else {
|
|
// Tagged format - get tasks from all tags
|
|
Object.values(tasksData).forEach(tagData => {
|
|
if (tagData.tasks) {
|
|
tasks = tasks.concat(tagData.tasks);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Calculate task statistics
|
|
const stats = tasks.reduce((acc, task) => {
|
|
acc.total++;
|
|
acc[task.status] = (acc[task.status] || 0) + 1;
|
|
|
|
// Count subtasks
|
|
if (task.subtasks) {
|
|
task.subtasks.forEach(subtask => {
|
|
acc.subtotalTasks++;
|
|
acc.subtasks = acc.subtasks || {};
|
|
acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
|
|
});
|
|
}
|
|
|
|
return acc;
|
|
}, {
|
|
total: 0,
|
|
subtotalTasks: 0,
|
|
pending: 0,
|
|
'in-progress': 0,
|
|
done: 0,
|
|
review: 0,
|
|
deferred: 0,
|
|
cancelled: 0,
|
|
subtasks: {}
|
|
});
|
|
|
|
taskMetadata = {
|
|
taskCount: stats.total,
|
|
subtaskCount: stats.subtotalTasks,
|
|
completed: stats.done || 0,
|
|
pending: stats.pending || 0,
|
|
inProgress: stats['in-progress'] || 0,
|
|
review: stats.review || 0,
|
|
completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
|
|
lastModified: (await fs.stat(tasksPath)).mtime.toISOString()
|
|
};
|
|
} catch (parseError) {
|
|
console.warn('Failed to parse tasks.json:', parseError.message);
|
|
taskMetadata = { error: 'Failed to parse tasks.json' };
|
|
}
|
|
}
|
|
|
|
return {
|
|
hasTaskmaster: true,
|
|
hasEssentialFiles,
|
|
files: fileStatus,
|
|
metadata: taskMetadata,
|
|
path: taskMasterPath
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Error detecting TaskMaster folder:', error);
|
|
return {
|
|
hasTaskmaster: false,
|
|
reason: `Error checking directory: ${error.message}`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Cache for extracted project directories
|
|
const projectDirectoryCache = new Map();
|
|
|
|
// Clear cache when needed (called when project files change)
|
|
function clearProjectDirectoryCache() {
|
|
projectDirectoryCache.clear();
|
|
}
|
|
|
|
// Load project configuration file
|
|
async function loadProjectConfig() {
|
|
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
|
|
try {
|
|
const configData = await fs.readFile(configPath, 'utf8');
|
|
return JSON.parse(configData);
|
|
} catch (error) {
|
|
// Return empty config if file doesn't exist
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// Save project configuration file
|
|
async function saveProjectConfig(config) {
|
|
const claudeDir = path.join(process.env.HOME, '.claude');
|
|
const configPath = path.join(claudeDir, 'project-config.json');
|
|
|
|
// Ensure the .claude directory exists
|
|
try {
|
|
await fs.mkdir(claudeDir, { recursive: true });
|
|
} catch (error) {
|
|
if (error.code !== 'EEXIST') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
}
|
|
|
|
// Generate better display name from path
|
|
async function generateDisplayName(projectName, actualProjectDir = null) {
|
|
// Use actual project directory if provided, otherwise decode from project name
|
|
let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
|
|
|
|
// Try to read package.json from the project path
|
|
try {
|
|
const packageJsonPath = path.join(projectPath, 'package.json');
|
|
const packageData = await fs.readFile(packageJsonPath, 'utf8');
|
|
const packageJson = JSON.parse(packageData);
|
|
|
|
// Return the name from package.json if it exists
|
|
if (packageJson.name) {
|
|
return packageJson.name;
|
|
}
|
|
} catch (error) {
|
|
// Fall back to path-based naming if package.json doesn't exist or can't be read
|
|
}
|
|
|
|
// If it starts with /, it's an absolute path
|
|
if (projectPath.startsWith('/')) {
|
|
const parts = projectPath.split('/').filter(Boolean);
|
|
// Return only the last folder name
|
|
return parts[parts.length - 1] || projectPath;
|
|
}
|
|
|
|
return projectPath;
|
|
}
|
|
|
|
// Extract the actual project directory from JSONL sessions (with caching)
|
|
async function extractProjectDirectory(projectName) {
|
|
// Check cache first
|
|
if (projectDirectoryCache.has(projectName)) {
|
|
return projectDirectoryCache.get(projectName);
|
|
}
|
|
|
|
|
|
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
|
const cwdCounts = new Map();
|
|
let latestTimestamp = 0;
|
|
let latestCwd = null;
|
|
let extractedPath;
|
|
|
|
try {
|
|
// Check if the project directory exists
|
|
await fs.access(projectDir);
|
|
|
|
const files = await fs.readdir(projectDir);
|
|
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
|
|
|
if (jsonlFiles.length === 0) {
|
|
// Fall back to decoded project name if no sessions
|
|
extractedPath = projectName.replace(/-/g, '/');
|
|
} else {
|
|
// Process all JSONL files to collect cwd values
|
|
for (const file of jsonlFiles) {
|
|
const jsonlFile = path.join(projectDir, file);
|
|
const fileStream = fsSync.createReadStream(jsonlFile);
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
if (line.trim()) {
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
|
|
if (entry.cwd) {
|
|
// Count occurrences of each cwd
|
|
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
|
|
|
|
// Track the most recent cwd
|
|
const timestamp = new Date(entry.timestamp || 0).getTime();
|
|
if (timestamp > latestTimestamp) {
|
|
latestTimestamp = timestamp;
|
|
latestCwd = entry.cwd;
|
|
}
|
|
}
|
|
} catch (parseError) {
|
|
// Skip malformed lines
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine the best cwd to use
|
|
if (cwdCounts.size === 0) {
|
|
// No cwd found, fall back to decoded project name
|
|
extractedPath = projectName.replace(/-/g, '/');
|
|
} else if (cwdCounts.size === 1) {
|
|
// Only one cwd, use it
|
|
extractedPath = Array.from(cwdCounts.keys())[0];
|
|
} else {
|
|
// Multiple cwd values - prefer the most recent one if it has reasonable usage
|
|
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
|
|
const maxCount = Math.max(...cwdCounts.values());
|
|
|
|
// Use most recent if it has at least 25% of the max count
|
|
if (mostRecentCount >= maxCount * 0.25) {
|
|
extractedPath = latestCwd;
|
|
} else {
|
|
// Otherwise use the most frequently used cwd
|
|
for (const [cwd, count] of cwdCounts.entries()) {
|
|
if (count === maxCount) {
|
|
extractedPath = cwd;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback (shouldn't reach here)
|
|
if (!extractedPath) {
|
|
extractedPath = latestCwd || projectName.replace(/-/g, '/');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cache the result
|
|
projectDirectoryCache.set(projectName, extractedPath);
|
|
|
|
return extractedPath;
|
|
|
|
} catch (error) {
|
|
// If the directory doesn't exist, just use the decoded project name
|
|
if (error.code === 'ENOENT') {
|
|
extractedPath = projectName.replace(/-/g, '/');
|
|
} else {
|
|
console.error(`Error extracting project directory for ${projectName}:`, error);
|
|
// Fall back to decoded project name for other errors
|
|
extractedPath = projectName.replace(/-/g, '/');
|
|
}
|
|
|
|
// Cache the fallback result too
|
|
projectDirectoryCache.set(projectName, extractedPath);
|
|
|
|
return extractedPath;
|
|
}
|
|
}
|
|
|
|
async function getProjects() {
|
|
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
|
|
const config = await loadProjectConfig();
|
|
const projects = [];
|
|
const existingProjects = new Set();
|
|
|
|
try {
|
|
// Check if the .claude/projects directory exists
|
|
await fs.access(claudeDir);
|
|
|
|
// First, get existing Claude projects from the file system
|
|
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
existingProjects.add(entry.name);
|
|
const projectPath = path.join(claudeDir, entry.name);
|
|
|
|
// Extract actual project directory from JSONL sessions
|
|
const actualProjectDir = await extractProjectDirectory(entry.name);
|
|
|
|
// Get display name from config or generate one
|
|
const customName = config[entry.name]?.displayName;
|
|
const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
|
|
const fullPath = actualProjectDir;
|
|
|
|
const project = {
|
|
name: entry.name,
|
|
path: actualProjectDir,
|
|
displayName: customName || autoDisplayName,
|
|
fullPath: fullPath,
|
|
isCustomName: !!customName,
|
|
sessions: []
|
|
};
|
|
|
|
// Try to get sessions for this project (just first 5 for performance)
|
|
try {
|
|
const sessionResult = await getSessions(entry.name, 5, 0);
|
|
project.sessions = sessionResult.sessions || [];
|
|
project.sessionMeta = {
|
|
hasMore: sessionResult.hasMore,
|
|
total: sessionResult.total
|
|
};
|
|
} catch (e) {
|
|
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
|
|
}
|
|
|
|
// Also fetch Cursor sessions for this project
|
|
try {
|
|
project.cursorSessions = await getCursorSessions(actualProjectDir);
|
|
} catch (e) {
|
|
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
|
|
project.cursorSessions = [];
|
|
}
|
|
|
|
// Add TaskMaster detection
|
|
try {
|
|
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
|
project.taskmaster = {
|
|
hasTaskmaster: taskMasterResult.hasTaskmaster,
|
|
hasEssentialFiles: taskMasterResult.hasEssentialFiles,
|
|
metadata: taskMasterResult.metadata,
|
|
status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured'
|
|
};
|
|
} catch (e) {
|
|
console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message);
|
|
project.taskmaster = {
|
|
hasTaskmaster: false,
|
|
hasEssentialFiles: false,
|
|
metadata: null,
|
|
status: 'error'
|
|
};
|
|
}
|
|
|
|
projects.push(project);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
|
|
if (error.code !== 'ENOENT') {
|
|
console.error('Error reading projects directory:', error);
|
|
}
|
|
}
|
|
|
|
// Add manually configured projects that don't exist as folders yet
|
|
for (const [projectName, projectConfig] of Object.entries(config)) {
|
|
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
|
|
// Use the original path if available, otherwise extract from potential sessions
|
|
let actualProjectDir = projectConfig.originalPath;
|
|
|
|
if (!actualProjectDir) {
|
|
try {
|
|
actualProjectDir = await extractProjectDirectory(projectName);
|
|
} catch (error) {
|
|
// Fall back to decoded project name
|
|
actualProjectDir = projectName.replace(/-/g, '/');
|
|
}
|
|
}
|
|
|
|
const project = {
|
|
name: projectName,
|
|
path: actualProjectDir,
|
|
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
|
|
fullPath: actualProjectDir,
|
|
isCustomName: !!projectConfig.displayName,
|
|
isManuallyAdded: true,
|
|
sessions: [],
|
|
cursorSessions: []
|
|
};
|
|
|
|
// Try to fetch Cursor sessions for manual projects too
|
|
try {
|
|
project.cursorSessions = await getCursorSessions(actualProjectDir);
|
|
} catch (e) {
|
|
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
|
|
}
|
|
|
|
// Add TaskMaster detection for manual projects
|
|
try {
|
|
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
|
|
|
// Determine TaskMaster status
|
|
let taskMasterStatus = 'not-configured';
|
|
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
|
|
taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk
|
|
}
|
|
|
|
project.taskmaster = {
|
|
status: taskMasterStatus,
|
|
hasTaskmaster: taskMasterResult.hasTaskmaster,
|
|
hasEssentialFiles: taskMasterResult.hasEssentialFiles,
|
|
metadata: taskMasterResult.metadata
|
|
};
|
|
} catch (error) {
|
|
console.warn(`TaskMaster detection failed for manual project ${projectName}:`, error.message);
|
|
project.taskmaster = {
|
|
status: 'error',
|
|
hasTaskmaster: false,
|
|
hasEssentialFiles: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
|
|
projects.push(project);
|
|
}
|
|
}
|
|
|
|
return projects;
|
|
}
|
|
|
|
async function getSessions(projectName, limit = 5, offset = 0) {
|
|
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
|
|
|
try {
|
|
const files = await fs.readdir(projectDir);
|
|
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
|
|
|
if (jsonlFiles.length === 0) {
|
|
return { sessions: [], hasMore: false, total: 0 };
|
|
}
|
|
|
|
// Sort files by modification time (newest first)
|
|
const filesWithStats = await Promise.all(
|
|
jsonlFiles.map(async (file) => {
|
|
const filePath = path.join(projectDir, file);
|
|
const stats = await fs.stat(filePath);
|
|
return { file, mtime: stats.mtime };
|
|
})
|
|
);
|
|
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
|
|
|
const allSessions = new Map();
|
|
const allEntries = [];
|
|
const uuidToSessionMap = new Map();
|
|
|
|
// Collect all sessions and entries from all files
|
|
for (const { file } of filesWithStats) {
|
|
const jsonlFile = path.join(projectDir, file);
|
|
const result = await parseJsonlSessions(jsonlFile);
|
|
|
|
result.sessions.forEach(session => {
|
|
if (!allSessions.has(session.id)) {
|
|
allSessions.set(session.id, session);
|
|
}
|
|
});
|
|
|
|
allEntries.push(...result.entries);
|
|
|
|
// Early exit optimization for large projects
|
|
if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Build UUID-to-session mapping for timeline detection
|
|
allEntries.forEach(entry => {
|
|
if (entry.uuid && entry.sessionId) {
|
|
uuidToSessionMap.set(entry.uuid, entry.sessionId);
|
|
}
|
|
});
|
|
|
|
// Group sessions by first user message ID
|
|
const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
|
|
const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
|
|
|
|
// Find the first user message for each session
|
|
allEntries.forEach(entry => {
|
|
if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
|
|
// This is a first user message in a session (parentUuid is null)
|
|
const firstUserMsgId = entry.uuid;
|
|
|
|
if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
|
|
sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
|
|
|
|
const session = allSessions.get(entry.sessionId);
|
|
if (session) {
|
|
if (!sessionGroups.has(firstUserMsgId)) {
|
|
sessionGroups.set(firstUserMsgId, {
|
|
latestSession: session,
|
|
allSessions: [session]
|
|
});
|
|
} else {
|
|
const group = sessionGroups.get(firstUserMsgId);
|
|
group.allSessions.push(session);
|
|
|
|
// Update latest session if this one is more recent
|
|
if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
|
|
group.latestSession = session;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Collect all sessions that don't belong to any group (standalone sessions)
|
|
const groupedSessionIds = new Set();
|
|
sessionGroups.forEach(group => {
|
|
group.allSessions.forEach(session => groupedSessionIds.add(session.id));
|
|
});
|
|
|
|
const standaloneSessionsArray = Array.from(allSessions.values())
|
|
.filter(session => !groupedSessionIds.has(session.id));
|
|
|
|
// Combine grouped sessions (only show latest from each group) + standalone sessions
|
|
const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
|
|
const session = { ...group.latestSession };
|
|
// Add metadata about grouping
|
|
if (group.allSessions.length > 1) {
|
|
session.isGrouped = true;
|
|
session.groupSize = group.allSessions.length;
|
|
session.groupSessions = group.allSessions.map(s => s.id);
|
|
}
|
|
return session;
|
|
});
|
|
const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
|
|
.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
|
|
|
const total = visibleSessions.length;
|
|
const paginatedSessions = visibleSessions.slice(offset, offset + limit);
|
|
const hasMore = offset + limit < total;
|
|
|
|
return {
|
|
sessions: paginatedSessions,
|
|
hasMore,
|
|
total,
|
|
offset,
|
|
limit
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error reading sessions for project ${projectName}:`, error);
|
|
return { sessions: [], hasMore: false, total: 0 };
|
|
}
|
|
}
|
|
|
|
async function parseJsonlSessions(filePath) {
|
|
const sessions = new Map();
|
|
const entries = [];
|
|
|
|
try {
|
|
const fileStream = fsSync.createReadStream(filePath);
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
if (line.trim()) {
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
entries.push(entry);
|
|
|
|
if (entry.sessionId) {
|
|
if (!sessions.has(entry.sessionId)) {
|
|
sessions.set(entry.sessionId, {
|
|
id: entry.sessionId,
|
|
summary: 'New Session',
|
|
messageCount: 0,
|
|
lastActivity: new Date(),
|
|
cwd: entry.cwd || ''
|
|
});
|
|
}
|
|
|
|
const session = sessions.get(entry.sessionId);
|
|
|
|
// Update summary from summary entries or first user message
|
|
if (entry.type === 'summary' && entry.summary) {
|
|
session.summary = entry.summary;
|
|
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
|
|
const content = entry.message.content;
|
|
if (typeof content === 'string' && content.length > 0 && !content.startsWith('<command-name>')) {
|
|
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
|
|
}
|
|
}
|
|
|
|
session.messageCount++;
|
|
|
|
if (entry.timestamp) {
|
|
session.lastActivity = new Date(entry.timestamp);
|
|
}
|
|
}
|
|
} catch (parseError) {
|
|
// Skip malformed lines silently
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
sessions: Array.from(sessions.values()),
|
|
entries: entries
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Error reading JSONL file:', error);
|
|
return { sessions: [], entries: [] };
|
|
}
|
|
}
|
|
|
|
// Get messages for a specific session with pagination support
|
|
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
|
|
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
|
|
|
try {
|
|
const files = await fs.readdir(projectDir);
|
|
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
|
|
|
if (jsonlFiles.length === 0) {
|
|
return { messages: [], total: 0, hasMore: false };
|
|
}
|
|
|
|
const messages = [];
|
|
|
|
// Process all JSONL files to find messages for this session
|
|
for (const file of jsonlFiles) {
|
|
const jsonlFile = path.join(projectDir, file);
|
|
const fileStream = fsSync.createReadStream(jsonlFile);
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
if (line.trim()) {
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
if (entry.sessionId === sessionId) {
|
|
messages.push(entry);
|
|
}
|
|
} catch (parseError) {
|
|
console.warn('Error parsing line:', parseError.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort messages by timestamp
|
|
const sortedMessages = messages.sort((a, b) =>
|
|
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
|
|
);
|
|
|
|
const total = sortedMessages.length;
|
|
|
|
// If no limit is specified, return all messages (backward compatibility)
|
|
if (limit === null) {
|
|
return sortedMessages;
|
|
}
|
|
|
|
// Apply pagination - for recent messages, we need to slice from the end
|
|
// offset 0 should give us the most recent messages
|
|
const startIndex = Math.max(0, total - offset - limit);
|
|
const endIndex = total - offset;
|
|
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
|
const hasMore = startIndex > 0;
|
|
|
|
return {
|
|
messages: paginatedMessages,
|
|
total,
|
|
hasMore,
|
|
offset,
|
|
limit
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error reading messages for session ${sessionId}:`, error);
|
|
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
|
}
|
|
}
|
|
|
|
// Rename a project's display name
|
|
async function renameProject(projectName, newDisplayName) {
|
|
const config = await loadProjectConfig();
|
|
|
|
if (!newDisplayName || newDisplayName.trim() === '') {
|
|
// Remove custom name if empty, will fall back to auto-generated
|
|
delete config[projectName];
|
|
} else {
|
|
// Set custom display name
|
|
config[projectName] = {
|
|
displayName: newDisplayName.trim()
|
|
};
|
|
}
|
|
|
|
await saveProjectConfig(config);
|
|
return true;
|
|
}
|
|
|
|
// Delete a session from a project
|
|
async function deleteSession(projectName, sessionId) {
|
|
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
|
|
|
try {
|
|
const files = await fs.readdir(projectDir);
|
|
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
|
|
|
if (jsonlFiles.length === 0) {
|
|
throw new Error('No session files found for this project');
|
|
}
|
|
|
|
// Check all JSONL files to find which one contains the session
|
|
for (const file of jsonlFiles) {
|
|
const jsonlFile = path.join(projectDir, file);
|
|
const content = await fs.readFile(jsonlFile, 'utf8');
|
|
const lines = content.split('\n').filter(line => line.trim());
|
|
|
|
// Check if this file contains the session
|
|
const hasSession = lines.some(line => {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
return data.sessionId === sessionId;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (hasSession) {
|
|
// Filter out all entries for this session
|
|
const filteredLines = lines.filter(line => {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
return data.sessionId !== sessionId;
|
|
} catch {
|
|
return true; // Keep malformed lines
|
|
}
|
|
});
|
|
|
|
// Write back the filtered content
|
|
await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
throw new Error(`Session ${sessionId} not found in any files`);
|
|
} catch (error) {
|
|
console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Check if a project is empty (has no sessions)
|
|
async function isProjectEmpty(projectName) {
|
|
try {
|
|
const sessionsResult = await getSessions(projectName, 1, 0);
|
|
return sessionsResult.total === 0;
|
|
} catch (error) {
|
|
console.error(`Error checking if project ${projectName} is empty:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Delete an empty project
|
|
async function deleteProject(projectName) {
|
|
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
|
|
|
try {
|
|
// First check if the project is empty
|
|
const isEmpty = await isProjectEmpty(projectName);
|
|
if (!isEmpty) {
|
|
throw new Error('Cannot delete project with existing sessions');
|
|
}
|
|
|
|
// Remove the project directory
|
|
await fs.rm(projectDir, { recursive: true, force: true });
|
|
|
|
// Remove from project config
|
|
const config = await loadProjectConfig();
|
|
delete config[projectName];
|
|
await saveProjectConfig(config);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error deleting project ${projectName}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Add a project manually to the config (without creating folders)
|
|
async function addProjectManually(projectPath, displayName = null) {
|
|
const absolutePath = path.resolve(projectPath);
|
|
|
|
try {
|
|
// Check if the path exists
|
|
await fs.access(absolutePath);
|
|
} catch (error) {
|
|
throw new Error(`Path does not exist: ${absolutePath}`);
|
|
}
|
|
|
|
// Generate project name (encode path for use as directory name)
|
|
const projectName = absolutePath.replace(/\//g, '-');
|
|
|
|
// Check if project already exists in config
|
|
const config = await loadProjectConfig();
|
|
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
|
|
|
if (config[projectName]) {
|
|
throw new Error(`Project already configured for path: ${absolutePath}`);
|
|
}
|
|
|
|
// Allow adding projects even if the directory exists - this enables tracking
|
|
// existing Claude Code or Cursor projects in the UI
|
|
|
|
// Add to config as manually added project
|
|
config[projectName] = {
|
|
manuallyAdded: true,
|
|
originalPath: absolutePath
|
|
};
|
|
|
|
if (displayName) {
|
|
config[projectName].displayName = displayName;
|
|
}
|
|
|
|
await saveProjectConfig(config);
|
|
|
|
|
|
return {
|
|
name: projectName,
|
|
path: absolutePath,
|
|
fullPath: absolutePath,
|
|
displayName: displayName || await generateDisplayName(projectName, absolutePath),
|
|
isManuallyAdded: true,
|
|
sessions: [],
|
|
cursorSessions: []
|
|
};
|
|
}
|
|
|
|
// Fetch Cursor sessions for a given project path
|
|
async function getCursorSessions(projectPath) {
|
|
try {
|
|
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
|
|
const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
|
|
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
|
|
|
// Check if the directory exists
|
|
try {
|
|
await fs.access(cursorChatsPath);
|
|
} catch (error) {
|
|
// No sessions for this project
|
|
return [];
|
|
}
|
|
|
|
// List all session directories
|
|
const sessionDirs = await fs.readdir(cursorChatsPath);
|
|
const sessions = [];
|
|
|
|
for (const sessionId of sessionDirs) {
|
|
const sessionPath = path.join(cursorChatsPath, sessionId);
|
|
const storeDbPath = path.join(sessionPath, 'store.db');
|
|
|
|
try {
|
|
// Check if store.db exists
|
|
await fs.access(storeDbPath);
|
|
|
|
// Capture store.db mtime as a reliable fallback timestamp
|
|
let dbStatMtimeMs = null;
|
|
try {
|
|
const stat = await fs.stat(storeDbPath);
|
|
dbStatMtimeMs = stat.mtimeMs;
|
|
} catch (_) {}
|
|
|
|
// Open SQLite database
|
|
const db = await open({
|
|
filename: storeDbPath,
|
|
driver: sqlite3.Database,
|
|
mode: sqlite3.OPEN_READONLY
|
|
});
|
|
|
|
// Get metadata from meta table
|
|
const metaRows = await db.all(`
|
|
SELECT key, value FROM meta
|
|
`);
|
|
|
|
// Parse metadata
|
|
let metadata = {};
|
|
for (const row of metaRows) {
|
|
if (row.value) {
|
|
try {
|
|
// Try to decode as hex-encoded JSON
|
|
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
|
|
if (hexMatch) {
|
|
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
|
|
metadata[row.key] = JSON.parse(jsonStr);
|
|
} else {
|
|
metadata[row.key] = row.value.toString();
|
|
}
|
|
} catch (e) {
|
|
metadata[row.key] = row.value.toString();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get message count
|
|
const messageCountResult = await db.get(`
|
|
SELECT COUNT(*) as count FROM blobs
|
|
`);
|
|
|
|
await db.close();
|
|
|
|
// Extract session info
|
|
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
|
|
|
|
// Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
|
|
let createdAt = null;
|
|
if (metadata.createdAt) {
|
|
createdAt = new Date(metadata.createdAt).toISOString();
|
|
} else if (dbStatMtimeMs) {
|
|
createdAt = new Date(dbStatMtimeMs).toISOString();
|
|
} else {
|
|
createdAt = new Date().toISOString();
|
|
}
|
|
|
|
sessions.push({
|
|
id: sessionId,
|
|
name: sessionName,
|
|
createdAt: createdAt,
|
|
lastActivity: createdAt, // For compatibility with Claude sessions
|
|
messageCount: messageCountResult.count || 0,
|
|
projectPath: projectPath
|
|
});
|
|
|
|
} catch (error) {
|
|
console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
|
|
}
|
|
}
|
|
|
|
// Sort sessions by creation time (newest first)
|
|
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
|
|
// Return only the first 5 sessions for performance
|
|
return sessions.slice(0, 5);
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching Cursor sessions:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
|
|
export {
|
|
getProjects,
|
|
getSessions,
|
|
getSessionMessages,
|
|
parseJsonlSessions,
|
|
renameProject,
|
|
deleteSession,
|
|
isProjectEmpty,
|
|
deleteProject,
|
|
addProjectManually,
|
|
loadProjectConfig,
|
|
saveProjectConfig,
|
|
extractProjectDirectory,
|
|
clearProjectDirectoryCache
|
|
}; |