/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { type Config } from '../config/config.js'; import { type Status } from '../core/coreToolScheduler.js'; import { type ThoughtSummary } from '../core/turn.js'; import { getProjectHash } from '../utils/paths.js'; import path from 'node:path'; import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; import { PartListUnion } from '@google/genai'; /** * Token usage summary for a message or conversation. */ export interface TokensSummary { input: number; // promptTokenCount output: number; // candidatesTokenCount cached: number; // cachedContentTokenCount thoughts?: number; // thoughtsTokenCount tool?: number; // toolUsePromptTokenCount total: number; // totalTokenCount } /** * Base fields common to all messages. */ export interface BaseMessageRecord { id: string; timestamp: string; content: string; } /** * Record of a tool call execution within a conversation. */ export interface ToolCallRecord { id: string; name: string; args: Record; result?: PartListUnion | null; status: Status; timestamp: string; // UI-specific fields for display purposes displayName?: string; description?: string; resultDisplay?: string; renderOutputAsMarkdown?: boolean; } /** * Message type and message type-specific fields. */ export type ConversationRecordExtra = | { type: 'user'; } | { type: 'gemini'; toolCalls?: ToolCallRecord[]; thoughts?: Array; tokens?: TokensSummary | null; model?: string; }; /** * A single message record in a conversation. */ export type MessageRecord = BaseMessageRecord & ConversationRecordExtra; /** * Complete conversation record stored in session files. */ export interface ConversationRecord { sessionId: string; projectHash: string; startTime: string; lastUpdated: string; messages: MessageRecord[]; } /** * Data structure for resuming an existing session. */ export interface ResumedSessionData { conversation: ConversationRecord; filePath: string; } /** * Service for automatically recording chat conversations to disk. * * This service provides comprehensive conversation recording that captures: * - All user and assistant messages * - Tool calls and their execution results * - Token usage statistics * - Assistant thoughts and reasoning * * Sessions are stored as JSON files in ~/.gemini/tmp//chats/ */ export class ChatRecordingService { private conversationFile: string | null = null; private cachedLastConvData: string | null = null; private sessionId: string; private projectHash: string; private queuedThoughts: Array = []; private queuedTokens: TokensSummary | null = null; private config: Config; constructor(config: Config) { this.config = config; this.sessionId = config.getSessionId(); this.projectHash = getProjectHash(config.getProjectRoot()); } /** * Initializes the chat recording service: creates a new conversation file and associates it with * this service instance, or resumes from an existing session if resumedSessionData is provided. */ initialize(resumedSessionData?: ResumedSessionData): void { try { if (resumedSessionData) { // Resume from existing session this.conversationFile = resumedSessionData.filePath; this.sessionId = resumedSessionData.conversation.sessionId; // Update the session ID in the existing file this.updateConversation((conversation) => { conversation.sessionId = this.sessionId; }); // Clear any cached data to force fresh reads this.cachedLastConvData = null; } else { // Create new session const chatsDir = path.join(this.config.getProjectTempDir(), 'chats'); fs.mkdirSync(chatsDir, { recursive: true }); const timestamp = new Date() .toISOString() .slice(0, 16) .replace(/:/g, '-'); const filename = `session-${timestamp}-${this.sessionId.slice( 0, 8, )}.json`; this.conversationFile = path.join(chatsDir, filename); this.writeConversation({ sessionId: this.sessionId, projectHash: this.projectHash, startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [], }); } // Clear any queued data since this is a fresh start this.queuedThoughts = []; this.queuedTokens = null; } catch (error) { console.error('Error initializing chat recording service:', error); throw error; } } private getLastMessage( conversation: ConversationRecord, ): MessageRecord | undefined { return conversation.messages.at(-1); } private newMessage( type: ConversationRecordExtra['type'], content: string, ): MessageRecord { return { id: randomUUID(), timestamp: new Date().toISOString(), type, content, }; } /** * Records a message in the conversation. */ recordMessage(message: { type: ConversationRecordExtra['type']; content: string; append?: boolean; }): void { if (!this.conversationFile) return; try { this.updateConversation((conversation) => { if (message.append) { const lastMsg = this.getLastMessage(conversation); if (lastMsg && lastMsg.type === message.type) { lastMsg.content += message.content; return; } } // We're not appending, or we are appending but the last message's type is not the same as // the specified type, so just create a new message. const msg = this.newMessage(message.type, message.content); if (msg.type === 'gemini') { // If it's a new Gemini message then incorporate any queued thoughts. conversation.messages.push({ ...msg, thoughts: this.queuedThoughts, tokens: this.queuedTokens, model: this.config.getModel(), }); this.queuedThoughts = []; this.queuedTokens = null; } else { // Or else just add it. conversation.messages.push(msg); } }); } catch (error) { console.error('Error saving message:', error); throw error; } } /** * Records a thought from the assistant's reasoning process. */ recordThought(thought: ThoughtSummary): void { if (!this.conversationFile) return; try { this.queuedThoughts.push({ ...thought, timestamp: new Date().toISOString(), }); } catch (error) { if (this.config.getDebugMode()) { console.error('Error saving thought:', error); throw error; } } } /** * Updates the tokens for the last message in the conversation (which should be by Gemini). */ recordMessageTokens(tokens: { input: number; output: number; cached: number; thoughts?: number; tool?: number; total: number; }): void { if (!this.conversationFile) return; try { this.updateConversation((conversation) => { const lastMsg = this.getLastMessage(conversation); // If the last message already has token info, it's because this new token info is for a // new message that hasn't been recorded yet. if (lastMsg && lastMsg.type === 'gemini' && !lastMsg.tokens) { lastMsg.tokens = tokens; this.queuedTokens = null; } else { this.queuedTokens = tokens; } }); } catch (error) { console.error('Error updating message tokens:', error); throw error; } } /** * Adds tool calls to the last message in the conversation (which should be by Gemini). */ recordToolCalls(toolCalls: ToolCallRecord[]): void { if (!this.conversationFile) return; try { this.updateConversation((conversation) => { const lastMsg = this.getLastMessage(conversation); // If a tool call was made, but the last message isn't from Gemini, it's because Gemini is // calling tools without starting the message with text. So the user submits a prompt, and // Gemini immediately calls a tool (maybe with some thinking first). In that case, create // a new empty Gemini message. // Also if there are any queued thoughts, it means this tool call(s) is from a new Gemini // message--because it's thought some more since we last, if ever, created a new Gemini // message from tool calls, when we dequeued the thoughts. if ( !lastMsg || lastMsg.type !== 'gemini' || this.queuedThoughts.length > 0 ) { const newMsg: MessageRecord = { ...this.newMessage('gemini' as const, ''), // This isn't strictly necessary, but TypeScript apparently can't // tell that the first parameter to newMessage() becomes the // resulting message's type, and so it thinks that toolCalls may // not be present. Confirming the type here satisfies it. type: 'gemini' as const, toolCalls, thoughts: this.queuedThoughts, model: this.config.getModel(), }; // If there are any queued thoughts join them to this message. if (this.queuedThoughts.length > 0) { newMsg.thoughts = this.queuedThoughts; this.queuedThoughts = []; } // If there's any queued tokens info join it to this message. if (this.queuedTokens) { newMsg.tokens = this.queuedTokens; this.queuedTokens = null; } conversation.messages.push(newMsg); } else { // The last message is an existing Gemini message that we need to update. // Update any existing tool call entries. if (!lastMsg.toolCalls) { lastMsg.toolCalls = []; } lastMsg.toolCalls = lastMsg.toolCalls.map((toolCall) => { // If there are multiple tool calls with the same ID, this will take the first one. const incomingToolCall = toolCalls.find( (tc) => tc.id === toolCall.id, ); if (incomingToolCall) { // Merge in the new data to keep preserve thoughts, etc., that were assigned to older // versions of the tool call. return { ...toolCall, ...incomingToolCall }; } else { return toolCall; } }); // Add any new tools calls that aren't in the message yet. for (const toolCall of toolCalls) { const existingToolCall = lastMsg.toolCalls.find( (tc) => tc.id === toolCall.id, ); if (!existingToolCall) { lastMsg.toolCalls.push(toolCall); } } } }); } catch (error) { console.error('Error adding tool call to message:', error); throw error; } } /** * Loads up the conversation record from disk. */ private readConversation(): ConversationRecord { try { this.cachedLastConvData = fs.readFileSync(this.conversationFile!, 'utf8'); return JSON.parse(this.cachedLastConvData); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Error reading conversation file:', error); throw error; } // Placeholder empty conversation if file doesn't exist. return { sessionId: this.sessionId, projectHash: this.projectHash, startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [], }; } } /** * Saves the conversation record; overwrites the file. */ private writeConversation(conversation: ConversationRecord): void { try { if (!this.conversationFile) return; // Don't write the file yet until there's at least one message. if (conversation.messages.length === 0) return; // Only write the file if this change would change the file. if (this.cachedLastConvData !== JSON.stringify(conversation, null, 2)) { conversation.lastUpdated = new Date().toISOString(); const newContent = JSON.stringify(conversation, null, 2); this.cachedLastConvData = newContent; fs.writeFileSync(this.conversationFile, newContent); } } catch (error) { console.error('Error writing conversation file:', error); throw error; } } /** * Convenient helper for updating the conversation without file reading and writing and time * updating boilerplate. */ private updateConversation( updateFn: (conversation: ConversationRecord) => void, ) { const conversation = this.readConversation(); updateFn(conversation); this.writeConversation(conversation); } /** * Deletes a session file by session ID. */ deleteSession(sessionId: string): void { try { const chatsDir = path.join(this.config.getProjectTempDir(), 'chats'); const sessionPath = path.join(chatsDir, `${sessionId}.json`); fs.unlinkSync(sessionPath); } catch (error) { console.error('Error deleting session:', error); throw error; } } }