UNPKG

@codervisor/devlog-ai

Version:

AI Chat History Extractor & Docker-based Automation - TypeScript implementation for GitHub Copilot and other AI coding assistants with automated testing capabilities

438 lines (384 loc) 14.6 kB
/** * GitHub Copilot chat history parser for VS Code * * This module handles parsing GitHub Copilot chat sessions from VS Code's * JSON storage files to extract actual conversation history. */ import { readFile, stat } from 'fs/promises'; import { resolve } from 'path'; import { homedir, platform } from 'os'; import fg from 'fast-glob'; import { ChatSession, ChatSessionData, Message, MessageData, WorkspaceData, WorkspaceDataContainer, } from '../../models/index.js'; import { AIAssistantParser, Logger } from '../base/ai-assistant-parser.js'; export class CopilotParser extends AIAssistantParser { constructor(logger?: Logger) { super(logger); } getAssistantName(): string { return 'GitHub Copilot'; } /** * Get VS Code storage paths based on platform */ protected getDataPaths(): string[] { const home = homedir(); const paths: string[] = []; switch (platform()) { case 'win32': // Windows const appDataRoaming = resolve(home, 'AppData', 'Roaming'); paths.push( resolve(appDataRoaming, 'Code', 'User'), resolve(appDataRoaming, 'Code - Insiders', 'User'), ); break; case 'darwin': // macOS const applicationSupport = resolve(home, 'Library', 'Application Support'); paths.push( resolve(applicationSupport, 'Code', 'User'), resolve(applicationSupport, 'Code - Insiders', 'User'), ); break; default: // Linux and others const config = resolve(home, '.config'); paths.push(resolve(config, 'Code', 'User'), resolve(config, 'Code - Insiders', 'User')); break; } return paths; } /** * Build mapping from workspace storage directory to actual workspace path */ private async buildWorkspaceMapping(basePath: string): Promise<Record<string, string>> { const workspaceMapping: Record<string, string> = {}; try { const workspaceStoragePath = resolve(basePath, 'workspaceStorage'); // Get all workspace directories const workspaceDirs = await fg('*/', { cwd: workspaceStoragePath, onlyDirectories: true, }); for (const workspaceDir of workspaceDirs) { const workspaceDirPath = resolve(workspaceStoragePath, workspaceDir); const workspaceJsonPath = resolve(workspaceDirPath, 'workspace.json'); try { const workspaceJsonContent = await readFile(workspaceJsonPath, 'utf-8'); const workspaceData = JSON.parse(workspaceJsonContent); const folderUri = workspaceData.folder || ''; if (folderUri.startsWith('file://')) { const folderPath = folderUri.slice(7); // Remove file:// prefix // Use the full path as the workspace identifier workspaceMapping[workspaceDir.replace('/', '')] = folderPath; this.logger.debug?.(`Mapped workspace ${workspaceDir} -> ${folderPath}`); } else if (workspaceData.workspace) { // Multi-root workspace const workspaceRef = workspaceData.workspace || ''; workspaceMapping[workspaceDir.replace('/', '')] = `multi-root: ${workspaceRef}`; this.logger.debug?.(`Mapped workspace ${workspaceDir} -> multi-root: ${workspaceRef}`); } } catch (error) { this.logger.debug?.( `Failed to read workspace.json from ${workspaceJsonPath}:`, error instanceof Error ? error.message : String(error), ); } } } catch (error) { this.logger.error?.( 'Error building workspace mapping:', error instanceof Error ? error.message : String(error), ); } return workspaceMapping; } /** * Parse actual chat session from JSON file */ async parseChatSession(filePath: string): Promise<ChatSession | null> { try { const fileContent = await readFile(filePath, 'utf-8'); const data = JSON.parse(fileContent); const sessionId = data.sessionId || resolve(filePath).split('/').pop()?.replace('.json', '') || ''; // Parse timestamps const creationDate = data.creationDate; const lastMessageDate = data.lastMessageDate; let timestamp: Date; if (creationDate) { try { timestamp = new Date(creationDate.replace('Z', '+00:00')); } catch { const fileStats = await stat(filePath); timestamp = new Date(fileStats.mtime); } } else { const fileStats = await stat(filePath); timestamp = new Date(fileStats.mtime); } // Extract messages from requests const messages: Message[] = []; for (const request of data.requests || []) { // User message const userMessageText = request.message?.text || ''; if (userMessageText) { const userMessage = new MessageData({ role: 'user', content: userMessageText, timestamp, id: request.requestId, metadata: { type: 'user_request', agent: request.agent || {}, variableData: request.variableData || {}, modelId: request.modelId, }, }); messages.push(userMessage); } // Assistant response const response = request.response; if (response) { let responseText = ''; if (typeof response === 'object') { if ('value' in response) { responseText = response.value; } else if ('text' in response) { responseText = response.text; } else if ('content' in response) { responseText = response.content; } } else if (typeof response === 'string') { responseText = response; } if (responseText) { const assistantMessage = new MessageData({ role: 'assistant', content: responseText, timestamp, id: request.responseId, metadata: { type: 'assistant_response', result: request.result || {}, followups: request.followups || [], isCanceled: request.isCanceled || false, contentReferences: request.contentReferences || [], codeCitations: request.codeCitations || [], requestTimestamp: request.timestamp, }, }); messages.push(assistantMessage); } } } const sessionMetadata = { version: data.version, requesterUsername: data.requesterUsername, responderUsername: data.responderUsername, initialLocation: data.initialLocation, creationDate, lastMessageDate, isImported: data.isImported, customTitle: data.customTitle, type: 'chat_session', source_file: filePath, total_requests: (data.requests || []).length, }; const session = new ChatSessionData({ agent: 'GitHub Copilot', timestamp, messages, workspace: undefined, // Will be set by caller session_id: sessionId, metadata: sessionMetadata, }); this.logger.info?.(`Parsed chat session ${sessionId} with ${messages.length} messages`); return session; } catch (error) { this.logger.error?.( `Error parsing chat session ${filePath}:`, error instanceof Error ? error.message : String(error), ); return null; } } /** * Parse chat editing session from state.json file (legacy format) */ async parseChatEditingSession(filePath: string): Promise<ChatSession | null> { try { const fileContent = await readFile(filePath, 'utf-8'); const data = JSON.parse(fileContent); const sessionId = data.sessionId || ''; const fileStats = await stat(filePath); const timestamp = new Date(fileStats.mtime); // Extract messages from linear history const messages: Message[] = []; for (let i = 0; i < (data.linearHistory || []).length; i++) { const historyEntry = data.linearHistory[i]; const requestId = historyEntry.requestId || `request_${i}`; const workingSet = historyEntry.workingSet || []; const entries = historyEntry.entries || []; // Create a descriptive message for the editing session let content = `Chat editing session with ${workingSet.length} files in working set`; if (entries.length > 0) { content += ` and ${entries.length} entries`; } const message = new MessageData({ role: 'user', // Changed from 'system' to match interface content, timestamp, id: requestId, metadata: { workingSet, entries, type: 'editing_session', }, }); messages.push(message); } // Add information about recent snapshot const recentSnapshot = data.recentSnapshot || {}; if (Object.keys(recentSnapshot).length > 0) { const snapshotMessage = new MessageData({ role: 'assistant', content: `Recent snapshot with ${(recentSnapshot.workingSet || []).length} files`, timestamp, id: `snapshot_${sessionId}`, metadata: { recentSnapshot, type: 'snapshot', }, }); messages.push(snapshotMessage); } const sessionMetadata = { version: data.version, linearHistoryIndex: data.linearHistoryIndex, initialFileContents: data.initialFileContents || [], recentSnapshot, type: 'chat_editing_session', source_file: filePath, }; const session = new ChatSessionData({ agent: 'GitHub Copilot', timestamp, messages, workspace: undefined, // Will be set by caller session_id: sessionId, metadata: sessionMetadata, }); this.logger.info?.( `Parsed chat editing session ${sessionId} with ${messages.length} entries`, ); return session; } catch (error) { this.logger.error?.( `Error parsing chat editing session ${filePath}:`, error instanceof Error ? error.message : String(error), ); return null; } } /** * Discover Copilot data from VS Code's application support directory */ async discoverChatData(): Promise<WorkspaceData> { const vscodePaths = this.getDataPaths(); // Collect all data from all VS Code installations const allData = new WorkspaceDataContainer({ agent: 'GitHub Copilot' }); for (const basePath of vscodePaths) { try { // Check if path exists await stat(basePath); this.logger.info?.(`Discovering Copilot data from: ${basePath}`); const data = await this.discoverCopilotData(basePath); if (data.chat_sessions.length > 0) { // Merge the data allData.chat_sessions.push(...data.chat_sessions); allData.workspace_path = basePath; // Use the last successful path // Merge metadata for (const [key, value] of Object.entries(data.metadata)) { if (key in allData.metadata) { if (Array.isArray(allData.metadata[key]) && Array.isArray(value)) { (allData.metadata[key] as unknown[]).push(...value); } else { allData.metadata[`${key}_${basePath.split('/').pop()}`] = value; } } else { allData.metadata[key] = value; } } } } catch (error) { // Path doesn't exist, continue to next this.logger.debug?.(`VS Code path not found: ${basePath}`); } } if (allData.chat_sessions.length === 0) { this.logger.warn?.('No chat sessions found in any VS Code installation'); } else { this.logger.info?.(`Total discovered: ${allData.chat_sessions.length} chat sessions`); } return allData; } /** * Discover and parse all Copilot data in a directory */ async discoverCopilotData(basePath: string): Promise<WorkspaceData> { const workspaceData = new WorkspaceDataContainer({ agent: 'GitHub Copilot', workspace_path: basePath, metadata: { discovery_source: basePath }, }); // Build workspace mapping from workspace.json files const workspaceMapping = await this.buildWorkspaceMapping(basePath); this.logger.info?.( `Built workspace mapping with ${Object.keys(workspaceMapping).length} workspaces`, ); // Look for actual chat session JSON files (new format) const chatSessionPattern = 'workspaceStorage/*/chatSessions/*.json'; const sessionFiles = await fg(chatSessionPattern, { cwd: basePath, absolute: true }); for (const sessionFile of sessionFiles) { const session = await this.parseChatSession(sessionFile); if (session) { // Extract workspace from file path using mapping const pathParts = sessionFile.split('/'); const workspaceId = pathParts[pathParts.indexOf('workspaceStorage') + 1]; if (workspaceId && workspaceMapping[workspaceId]) { session.workspace = workspaceMapping[workspaceId]; } workspaceData.chat_sessions.push(session); } } // Look for chat editing session files (legacy format) const editingSessionPattern = 'workspaceStorage/*/chatEditingSessions/*/state.json'; const editingSessionFiles = await fg(editingSessionPattern, { cwd: basePath, absolute: true }); for (const sessionFile of editingSessionFiles) { const session = await this.parseChatEditingSession(sessionFile); if (session) { // Extract workspace from file path using mapping const pathParts = sessionFile.split('/'); const workspaceStorageIndex = pathParts.indexOf('workspaceStorage'); const workspaceId = pathParts[workspaceStorageIndex + 1]; if (workspaceId && workspaceMapping[workspaceId]) { session.workspace = workspaceMapping[workspaceId]; } workspaceData.chat_sessions.push(session); } } this.logger.info?.( `Discovered ${workspaceData.chat_sessions.length} chat sessions from ${basePath}`, ); return workspaceData; } // Legacy method name for backwards compatibility async discoverVSCodeCopilotData(): Promise<WorkspaceData> { return this.discoverChatData(); } }