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, compactJson, isInScanRoots, stableMessageId, toDateOrUndefined, } from "./chat-types"; interface GeminiSessionFile { sessionId?: string; projectHash?: string; startTime?: string; lastUpdated?: string; messages?: GeminiMessageFile[]; } interface GeminiMessageFile { id?: string; timestamp?: string; type?: string; content?: string; model?: string; thoughts?: Array<{ subject?: string; description?: string }>; toolCalls?: unknown[]; tokens?: { input?: number; output?: number; }; } function mapGeminiRole(type: string | undefined): ChatMessage["role"] { switch (type) { case "user": return "user"; case "gemini": return "assistant"; case "tool": return "tool"; default: return "system"; } } function extractGeminiText(message: GeminiMessageFile): string { const parts: string[] = []; if (typeof message.content === "string" && message.content.trim()) { parts.push(message.content.trim()); } if (Array.isArray(message.thoughts) && message.thoughts.length > 0) { for (const thought of message.thoughts) { const summary = [thought.subject, thought.description] .filter((v) => typeof v === "string" && v.trim()) .join(": "); if (summary) { parts.push(summary); } } } if (Array.isArray(message.toolCalls) && message.toolCalls.length > 0) { const summary = compactJson(message.toolCalls, 8000); if (summary) { parts.push(`Tool calls: ${summary}`); } } return clampText(parts.join("\n\n"), 32000); } async function existsDir(path: string): Promise { try { const stats = await stat(path); return stats.isDirectory(); } catch { return false; } } async function collectGeminiProjectDirs(roots: string[]): Promise { const dirs = new Set(); for (const root of roots) { if (!(await existsDir(root))) { continue; } const directChatsDir = join(root, "chats"); if (await existsDir(directChatsDir)) { dirs.add(root); continue; } try { const entries = await readdir(root, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const candidate = join(root, entry.name); if (await existsDir(join(candidate, "chats"))) { dirs.add(candidate); } } } catch { // Ignore inaccessible roots. } } return Array.from(dirs); } export function getGeminiTmpDir(): string { return join(homedir(), ".gemini", "tmp"); } export async function parseGeminiSessionFile( filePath: string, projectHash: string, ): Promise { try { const content = await readFile(filePath, "utf-8"); const session = JSON.parse(content) as GeminiSessionFile; const externalThreadId = session.sessionId || basename(filePath).replace(/\.json$/, ""); const messages: ChatMessage[] = []; const rawMessages = Array.isArray(session.messages) ? session.messages : []; for (let i = 0; i < rawMessages.length; i++) { const message = rawMessages[i]; const timestamp = toDateOrUndefined(message.timestamp); if (!timestamp) continue; const role = mapGeminiRole(message.type); const text = extractGeminiText(message); // Skip empty system/control records with no searchable content. if (!text) continue; messages.push({ externalMessageId: message.id || stableMessageId(externalThreadId, i, `m-${i}`), role, timestamp, text, tokenIn: message.tokens?.input, tokenOut: message.tokens?.output, rawJson: { type: message.type, model: message.model, hasThoughts: Array.isArray(message.thoughts) ? message.thoughts.length > 0 : false, hasToolCalls: Array.isArray(message.toolCalls) ? message.toolCalls.length > 0 : false, }, }); } if (messages.length === 0) { return null; } messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); return { providerId: "gemini", externalThreadId, projectHash: session.projectHash || projectHash, conversationFile: filePath, startedAt: toDateOrUndefined(session.startTime) || toDateOrUndefined(messages[0]?.timestamp), endedAt: toDateOrUndefined(session.lastUpdated) || toDateOrUndefined(messages[messages.length - 1]?.timestamp), metadata: { projectHash: session.projectHash || projectHash, }, messages, }; } catch { return null; } } export async function* getGeminiSessionsStreaming( scanRoots: string[], since?: Date, options?: { dataRoots?: string[] }, ): AsyncGenerator { const projectDirs = await collectGeminiProjectDirs( options?.dataRoots ?? [getGeminiTmpDir()], ); for (const projectDir of projectDirs) { const chatsDir = join(projectDir, "chats"); const projectHash = basename(projectDir); let files: string[] = []; try { files = await readdir(chatsDir); } catch { continue; } for (const file of files) { if (!file.startsWith("session-") || !file.endsWith(".json")) continue; const filePath = join(chatsDir, file); if (since) { try { const fileStats = await stat(filePath); if (fileStats.mtime < since) continue; } catch { continue; } } const session = await parseGeminiSessionFile(filePath, projectHash); 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; } } }