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, compactJson, isInScanRoots, stableMessageId, toDateOrUndefined, } from "./chat-types"; interface OpenCodeSessionFile { id?: string; projectID?: string; directory?: string; title?: string; slug?: string; version?: string; time?: { created?: number; updated?: number; }; } interface OpenCodeMessageFile { id?: string; sessionID?: string; role?: string; time?: { created?: number; completed?: number; }; modelID?: string; providerID?: string; finish?: string; summary?: { title?: string; }; } interface OpenCodePartFile { id?: string; sessionID?: string; messageID?: string; type?: string; text?: string; reason?: string; tool?: string; files?: string[]; state?: { status?: string; input?: unknown; output?: unknown; }; tokens?: { input?: number; output?: number; }; time?: { start?: number; end?: number; }; } async function existsDir(path: string): Promise { try { const stats = await stat(path); return stats.isDirectory(); } catch { return false; } } function roleFromOpenCodeRole(role: string | undefined): ChatRole { if (role === "user") return "user"; if (role === "assistant") return "assistant"; if (role === "tool") return "tool"; return "system"; } function partTimestamp(part: OpenCodePartFile, fallback: Date): Date { return ( toDateOrUndefined(part.time?.start) || toDateOrUndefined(part.time?.end) || fallback ); } function extractPartText(part: OpenCodePartFile): string { const type = part.type || "unknown"; if ((type === "text" || type === "reasoning") && part.text) { return clampText(part.text, 32000); } if (type === "tool") { const toolName = typeof part.tool === "string" ? part.tool : "tool"; const status = typeof part.state?.status === "string" ? part.state.status : "unknown"; const input = compactJson(part.state?.input, 6000); const output = compactJson(part.state?.output, 12000); const sections = [`Tool: ${toolName} (${status})`]; if (input) sections.push(`Input:\n${input}`); if (output) sections.push(`Output:\n${output}`); return clampText(sections.join("\n\n"), 32000); } if (type === "patch") { const files = Array.isArray(part.files) ? part.files : []; if (files.length === 0) { return "Patch applied"; } return clampText(`Patch files:\n${files.join("\n")}`, 32000); } if (type === "step-start") { return "Step started"; } if (type === "step-finish") { return part.reason ? `Step finished: ${part.reason}` : "Step finished"; } return ""; } function roleForPart(part: OpenCodePartFile, fallback: ChatRole): ChatRole { const type = part.type; if (type === "tool" || type === "patch") return "tool"; if (type === "step-start" || type === "step-finish") return "system"; return fallback; } async function readJsonFile(filePath: string): Promise { try { const content = await readFile(filePath, "utf-8"); return JSON.parse(content) as T; } catch { return null; } } async function listJsonFiles(path: string): Promise { try { const entries = await readdir(path, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .map((entry) => join(path, entry.name)); } catch { return []; } } async function parseOpenCodeMessageParts( storageRoot: string, message: OpenCodeMessageFile, ): Promise { const messageId = message.id; if (!messageId) return []; const baseTime = toDateOrUndefined(message.time?.created) || toDateOrUndefined(message.time?.completed) || new Date(); const fallbackRole = roleFromOpenCodeRole(message.role); const partDir = join(storageRoot, "part", messageId); const partFiles = await listJsonFiles(partDir); const parts: OpenCodePartFile[] = []; for (const filePath of partFiles) { const part = await readJsonFile(filePath); if (!part) continue; parts.push(part); } parts.sort( (a, b) => partTimestamp(a, baseTime).getTime() - partTimestamp(b, baseTime).getTime(), ); const messages: ChatMessage[] = []; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const text = extractPartText(part); if (!text) continue; const externalMessageId = (typeof part.id === "string" && part.id) || stableMessageId(messageId, i, `part-${i}`); messages.push({ externalMessageId, role: roleForPart(part, fallbackRole), timestamp: partTimestamp(part, baseTime), text, toolName: typeof part.tool === "string" ? part.tool : undefined, tokenIn: part.tokens?.input, tokenOut: part.tokens?.output, rawJson: { type: part.type, tool: part.tool, status: part.state?.status, reason: part.reason, }, }); } // Fallback for messages that have no parts but do have summary metadata. if (messages.length === 0 && message.summary?.title) { messages.push({ externalMessageId: stableMessageId(messageId, 0, messageId), role: fallbackRole, timestamp: baseTime, text: clampText(message.summary.title, 32000), rawJson: { type: "summary", }, }); } return messages; } export function getOpenCodeStorageDir(): string { return join(homedir(), ".local", "share", "opencode", "storage"); } async function collectOpenCodeStorageRoots(roots: string[]): Promise { const storageRoots = new Set(); for (const root of roots) { if (!(await existsDir(root))) continue; if ( (await existsDir(join(root, "session"))) && (await existsDir(join(root, "message"))) ) { storageRoots.add(root); continue; } const nestedStorage = join(root, "storage"); if ( (await existsDir(join(nestedStorage, "session"))) && (await existsDir(join(nestedStorage, "message"))) ) { storageRoots.add(nestedStorage); } } return Array.from(storageRoots); } export async function parseOpenCodeSessionFile( storageRoot: string, sessionFilePath: string, ): Promise { const session = await readJsonFile(sessionFilePath); if (!session?.id) return null; const messageDir = join(storageRoot, "message", session.id); const messageFiles = await listJsonFiles(messageDir); const parsedMessages: OpenCodeMessageFile[] = []; for (const filePath of messageFiles) { const message = await readJsonFile(filePath); if (!message?.id) continue; parsedMessages.push(message); } parsedMessages.sort((a, b) => { const aTime = toDateOrUndefined(a.time?.created)?.getTime() || 0; const bTime = toDateOrUndefined(b.time?.created)?.getTime() || 0; return aTime - bTime; }); const messages: ChatMessage[] = []; for (const message of parsedMessages) { const messageParts = await parseOpenCodeMessageParts(storageRoot, message); messages.push(...messageParts); } if (messages.length === 0) { return null; } messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); return { providerId: "opencode", externalThreadId: session.id, projectPath: session.directory, title: session.title, conversationFile: sessionFilePath, startedAt: toDateOrUndefined(session.time?.created) || messages[0]?.timestamp, endedAt: toDateOrUndefined(session.time?.updated) || messages[messages.length - 1]?.timestamp, metadata: { projectId: session.projectID, slug: session.slug, version: session.version, }, messages, }; } export async function* getOpenCodeSessionsStreaming( scanRoots: string[], since?: Date, options?: { dataRoots?: string[] }, ): AsyncGenerator { const storageRoots = await collectOpenCodeStorageRoots( options?.dataRoots ?? [getOpenCodeStorageDir()], ); for (const storageRoot of storageRoots) { const sessionRoot = join(storageRoot, "session"); let projectDirs: string[] = []; try { const entries = await readdir(sessionRoot, { withFileTypes: true }); projectDirs = entries .filter((entry) => entry.isDirectory()) .map((entry) => join(sessionRoot, entry.name)); } catch { continue; } for (const projectDir of projectDirs) { const sessionFiles = await listJsonFiles(projectDir); for (const sessionFile of sessionFiles) { if (since) { try { const fileStats = await stat(sessionFile); if (fileStats.mtime < since) continue; } catch { continue; } } const session = await parseOpenCodeSessionFile( storageRoot, sessionFile, ); if (!session) continue; if (!isInScanRoots(session.projectPath, scanRoots)) continue; if (since) { const filteredMessages = session.messages.filter( (message) => message.timestamp >= since, ); if (filteredMessages.length === 0) continue; session.messages = filteredMessages; } yield session; } } } }