import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { type ChatMessage, type ChatRole, type ChatSession, clampText, isInScanRoots, stableMessageId, toDateOrUndefined, } from "./chat-types"; // VS Code extension IDs that use the Cline storage format const CLINE_EXTENSION_IDS = [ "saoudrizwan.claude-dev", // Cline (original) "rooveterinaryinc.roo-cline", // Roo Cline fork ] as const; interface ClineMessage { ts: number; type: "ask" | "say"; ask?: string; say?: string; text?: string; reasoning?: string; images?: string[]; partial?: boolean; conversationHistoryIndex?: number; } interface ClineHistoryItem { id: string; ts: number; task: string; tokensIn: number; tokensOut: number; totalCost: number; cwdOnTaskInitialization?: string; modelId?: string; } async function existsDir(path: string): Promise { try { const s = await stat(path); return s.isDirectory(); } catch { return false; } } async function readJsonFile(filePath: string): Promise { try { const content = await readFile(filePath, "utf-8"); return JSON.parse(content) as T; } catch { return null; } } function vscodeGlobalStoragePath(extensionId: string): string { const platform = process.platform; const home = homedir(); if (platform === "darwin") { return join( home, "Library", "Application Support", "Code", "User", "globalStorage", extensionId, ); } if (platform === "win32") { const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming"); return join(appData, "Code", "User", "globalStorage", extensionId); } const configHome = process.env.XDG_CONFIG_HOME ?? join(home, ".config"); return join(configHome, "Code", "User", "globalStorage", extensionId); } export function getClineDefaultScanRoots(): string[] { return CLINE_EXTENSION_IDS.map((id) => vscodeGlobalStoragePath(id)); } async function collectClineStorageRoots( candidates: string[], ): Promise { const results: string[] = []; for (const root of candidates) { if (await existsDir(join(root, "tasks"))) { results.push(root); } } return results; } function clineMessageRole(msg: ClineMessage): ChatRole { if (msg.type === "say") { const say = msg.say ?? ""; if (say === "task" || say === "user_feedback") return "user"; if (say === "text" || say === "completion_result" || say === "reasoning") return "assistant"; if (say === "tool") return "tool"; return "system"; } const ask = msg.ask ?? ""; if (ask === "tool" || ask === "command") return "tool"; return "assistant"; } function shouldIncludeClineMessage(msg: ClineMessage): boolean { if (!msg.text?.trim()) return false; if (msg.type === "say") { const say = msg.say ?? ""; const skip = new Set([ "api_req_started", "api_req_finished", "api_req_retried", "deleted_api_reqs", "checkpoint_created", "shell_integration_warning", "shell_integration_warning_with_suggestion", "clineignore_error", "command_permission_denied", "task_progress", "hook_status", "hook_output_stream", "subagent", "subagent_usage", "conditional_rules_applied", "diff_error", "error", "error_retry", ]); if (skip.has(say)) return false; } return true; } async function parseClineUiMessages( taskDir: string, taskId: string, fileModTime: Date, ): Promise { const rawMessages = await readJsonFile( join(taskDir, "ui_messages.json"), ); if (!rawMessages || !Array.isArray(rawMessages)) return []; const messages: ChatMessage[] = []; for (let i = 0; i < rawMessages.length; i++) { const msg = rawMessages[i]; if (!shouldIncludeClineMessage(msg)) continue; const text = clampText(msg.text?.trim() ?? "", 32000); if (!text) continue; messages.push({ externalMessageId: stableMessageId(taskId, i, `${msg.type}-${i}`), role: clineMessageRole(msg), timestamp: toDateOrUndefined(msg.ts) ?? fileModTime, text, rawJson: { type: msg.type, ask: msg.ask, say: msg.say }, }); } return messages; } export async function* getClineSessionsStreaming( scanRoots: string[], since?: Date, options?: { dataRoots?: string[] }, ): AsyncGenerator { const storageRoots = await collectClineStorageRoots( options?.dataRoots ?? getClineDefaultScanRoots(), ); for (const storageRoot of storageRoots) { const historyItems = (await readJsonFile( join(storageRoot, "state", "taskHistory.json"), )) ?? []; const historyById = new Map(); for (const item of historyItems) { if (item.id) historyById.set(item.id, item); } let taskEntries: string[] = []; try { const entries = await readdir(join(storageRoot, "tasks"), { withFileTypes: true, }); taskEntries = entries.filter((e) => e.isDirectory()).map((e) => e.name); } catch { continue; } for (const taskId of taskEntries) { const taskDir = join(storageRoot, "tasks", taskId); const uiMessagesPath = join(taskDir, "ui_messages.json"); let fileModTime: Date; try { const fileStats = await stat(uiMessagesPath); fileModTime = fileStats.mtime; if (since && fileModTime < since) continue; } catch { continue; } const historyItem = historyById.get(taskId); const messages = await parseClineUiMessages(taskDir, taskId, fileModTime); if (messages.length === 0) continue; const filtered = since ? messages.filter((m) => m.timestamp >= since) : messages; if (filtered.length === 0) continue; filtered.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); const taskText = historyItem?.task ?? ""; const title = taskText ? (taskText.split("\n")[0]?.slice(0, 120) ?? taskText.slice(0, 120)) : taskId; const session: ChatSession = { providerId: "cline", externalThreadId: taskId, projectPath: historyItem?.cwdOnTaskInitialization, title, conversationFile: uiMessagesPath, startedAt: toDateOrUndefined(historyItem?.ts) ?? filtered[0]?.timestamp, endedAt: filtered[filtered.length - 1]?.timestamp, metadata: { taskId, storageRoot, modelId: historyItem?.modelId, totalCost: historyItem?.totalCost, }, messages: filtered, }; if (!isInScanRoots(session.projectPath, scanRoots)) continue; yield session; } } }