import type { Database } from "bun:sqlite"; import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { type ChatMessage, type ChatSession, clampText, isInScanRoots, stableMessageId, toDateOrUndefined, } from "./chat-types"; /** * GitHub Copilot Chat adapter. * * Copilot Chat stores conversation history in VS Code's workspace storage as * SQLite databases (state.vscdb). The relevant key in the ItemTable is * "interactive.sessions", which contains a JSON array of session objects. * * Storage location (macOS): * ~/Library/Application Support/Code/User/workspaceStorage//state.vscdb * ~/Library/Application Support/Code - Insiders/User/workspaceStorage//state.vscdb * * Session JSON format: * [{ * "sessionId": "uuid", * "requests": [{ * "message": { "text": "user question" }, * "response": [{ "value": "assistant answer" }], * "timestamp": 1704067200000 * }] * }] * * Workspace path is resolved from the same workspaceStorage hash via * workspace.json: { "folder": "file:///path/to/project" } */ interface CopilotRequest { message?: { text?: string }; response?: Array<{ value?: string }>; timestamp?: number; } interface CopilotSession { sessionId?: string; requests?: CopilotRequest[]; } interface WorkspaceJson { folder?: string; folders?: Array<{ uri?: string; path?: string }>; } function vscodeWorkspaceStoragePaths(): string[] { const home = homedir(); if (process.platform === "darwin") { return [ join( home, "Library", "Application Support", "Code", "User", "workspaceStorage", ), join( home, "Library", "Application Support", "Code - Insiders", "User", "workspaceStorage", ), ]; } if (process.platform === "win32") { const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming"); return [join(appData, "Code", "User", "workspaceStorage")]; } const configHome = process.env.XDG_CONFIG_HOME ?? join(home, ".config"); return [ join(configHome, "Code", "User", "workspaceStorage"), join(configHome, "Code - Insiders", "User", "workspaceStorage"), ]; } export function getCopilotDefaultScanRoots(): string[] { return vscodeWorkspaceStoragePaths(); } async function existsDir(path: string): Promise { try { const s = await stat(path); return s.isDirectory(); } catch { return false; } } async function resolveWorkspacePath( hashDir: string, ): Promise { const workspaceJsonPath = join(hashDir, "workspace.json"); try { const raw = await readFile(workspaceJsonPath, "utf-8"); const ws = JSON.parse(raw) as WorkspaceJson; const uri = ws.folder ?? ws.folders?.[0]?.uri ?? ws.folders?.[0]?.path; if (!uri) return undefined; // Convert file:/// URI to path if (uri.startsWith("file://")) { return decodeURIComponent(uri.replace(/^file:\/\//, "")); } return uri; } catch { return undefined; } } async function openReadonlySqlite(dbPath: string): Promise { const { Database } = await import("bun:sqlite"); return new Database(dbPath, { readonly: true }); } async function readCopilotSessionsFromDb( dbPath: string, ): Promise { let db: Database | undefined; try { db = await openReadonlySqlite(dbPath); const row = db .query<{ value: string }, []>( "SELECT value FROM ItemTable WHERE key = 'interactive.sessions' LIMIT 1", ) .get(); if (!row?.value) return []; const parsed = JSON.parse(row.value); return Array.isArray(parsed) ? (parsed as CopilotSession[]) : []; } catch { return []; } finally { db?.close(); } } function parseCopilotSession( session: CopilotSession, workspacePath: string | undefined, dbPath: string, workspaceHash: string, ): ChatSession | null { const requests = session.requests ?? []; if (requests.length === 0) return null; const messages: ChatMessage[] = []; const sessionId = session.sessionId ?? workspaceHash; for (let i = 0; i < requests.length; i++) { const req = requests[i]; const ts = toDateOrUndefined(req.timestamp) ?? new Date(); const userText = req.message?.text?.trim(); if (userText) { messages.push({ externalMessageId: stableMessageId(sessionId, i * 2, `user-${i}`), role: "user", timestamp: ts, text: clampText(userText, 32000), }); } const assistantParts = (req.response ?? []) .map((r) => r.value?.trim()) .filter(Boolean); if (assistantParts.length > 0) { messages.push({ externalMessageId: stableMessageId(sessionId, i * 2 + 1, `asst-${i}`), role: "assistant", timestamp: ts, text: clampText(assistantParts.join("\n\n"), 32000), }); } } if (messages.length === 0) return null; messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); return { providerId: "copilot", externalThreadId: sessionId, projectPath: workspacePath, projectHash: workspaceHash, conversationFile: dbPath, startedAt: messages[0].timestamp, endedAt: messages[messages.length - 1].timestamp, metadata: { workspaceHash, dbPath }, messages, }; } export async function* getCopilotSessionsStreaming( scanRoots: string[], since?: Date, options?: { dataRoots?: string[] }, ): AsyncGenerator { const roots = options?.dataRoots ?? getCopilotDefaultScanRoots(); for (const root of roots) { if (!(await existsDir(root))) continue; let hashDirs: string[] = []; try { const entries = await readdir(root, { withFileTypes: true }); hashDirs = entries .filter((e) => e.isDirectory()) .map((e) => join(root, e.name)); } catch { continue; } for (const hashDir of hashDirs) { const dbPath = join(hashDir, "state.vscdb"); // Only process workspaces that have Copilot Chat data // Quick check: see if the db file exists and was modified recently let dbMtime: Date; try { const s = await stat(dbPath); dbMtime = s.mtime; if (since && dbMtime < since) continue; } catch { continue; } const workspaceHash = basename(hashDir); const workspacePath = await resolveWorkspacePath(hashDir); const copilotSessions = await readCopilotSessionsFromDb(dbPath); if (copilotSessions.length === 0) continue; for (const session of copilotSessions) { const chatSession = parseCopilotSession( session, workspacePath, dbPath, workspaceHash, ); if (!chatSession) continue; if (!isInScanRoots(chatSession.projectPath, scanRoots)) continue; if (since) { chatSession.messages = chatSession.messages.filter( (m) => m.timestamp >= since, ); if (chatSession.messages.length === 0) continue; } yield chatSession; } } } }