/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import path from 'node:path'; import { promises as fs } from 'node:fs'; const GEMINI_DIR = '.gemini'; const LOG_FILE_NAME = 'logs.json'; export enum MessageSenderType { USER = 'user', } export interface LogEntry { sessionId: number; messageId: number; timestamp: string; type: MessageSenderType; message: string; } export class Logger { private logFilePath: string | undefined; private sessionId: number | undefined; private messageId = 0; // Instance-specific counter for the next messageId private initialized = false; private logs: LogEntry[] = []; // In-memory cache, ideally reflects the last known state of the file constructor() {} private async _readLogFile(): Promise { if (!this.logFilePath) { throw new Error('Log file path not set during read attempt.'); } try { const fileContent = await fs.readFile(this.logFilePath, 'utf-8'); const parsedLogs = JSON.parse(fileContent); if (!Array.isArray(parsedLogs)) { console.debug( `Log file at ${this.logFilePath} is not a valid JSON array. Starting with empty logs.`, ); await this._backupCorruptedLogFile('malformed_array'); return []; } return parsedLogs.filter( (entry) => typeof entry.sessionId === 'number' && typeof entry.messageId === 'number' && typeof entry.timestamp === 'string' && typeof entry.type === 'string' && typeof entry.message === 'string', ) as LogEntry[]; } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') { return []; } if (error instanceof SyntaxError) { console.debug( `Invalid JSON in log file ${this.logFilePath}. Backing up and starting fresh.`, error, ); await this._backupCorruptedLogFile('invalid_json'); return []; } console.debug( `Failed to read or parse log file ${this.logFilePath}:`, error, ); throw error; } } private async _backupCorruptedLogFile(reason: string): Promise { if (!this.logFilePath) return; const backupPath = `${this.logFilePath}.${reason}.${Date.now()}.bak`; try { await fs.rename(this.logFilePath, backupPath); console.debug(`Backed up corrupted log file to ${backupPath}`); } catch (_backupError) { // If rename fails (e.g. file doesn't exist), no need to log an error here as the primary error (e.g. invalid JSON) is already handled. } } async initialize(): Promise { if (this.initialized) { return; } this.sessionId = Math.floor(Date.now() / 1000); const geminiDir = path.resolve(process.cwd(), GEMINI_DIR); this.logFilePath = path.join(geminiDir, LOG_FILE_NAME); try { await fs.mkdir(geminiDir, { recursive: true }); let fileExisted = true; try { await fs.access(this.logFilePath); } catch (_e) { fileExisted = false; } this.logs = await this._readLogFile(); if (!fileExisted && this.logs.length === 0) { await fs.writeFile(this.logFilePath, '[]', 'utf-8'); } const sessionLogs = this.logs.filter( (entry) => entry.sessionId === this.sessionId, ); this.messageId = sessionLogs.length > 0 ? Math.max(...sessionLogs.map((entry) => entry.messageId)) + 1 : 0; this.initialized = true; } catch (err) { console.error('Failed to initialize logger:', err); this.initialized = false; } } private async _updateLogFile( entryToAppend: LogEntry, ): Promise { if (!this.logFilePath) { console.debug('Log file path not set. Cannot persist log entry.'); throw new Error('Log file path not set during update attempt.'); } let currentLogsOnDisk: LogEntry[]; try { currentLogsOnDisk = await this._readLogFile(); } catch (readError) { console.debug( 'Critical error reading log file before append:', readError, ); throw readError; } // Determine the correct messageId for the new entry based on current disk state for its session const sessionLogsOnDisk = currentLogsOnDisk.filter( (e) => e.sessionId === entryToAppend.sessionId, ); const nextMessageIdForSession = sessionLogsOnDisk.length > 0 ? Math.max(...sessionLogsOnDisk.map((e) => e.messageId)) + 1 : 0; // Update the messageId of the entry we are about to append entryToAppend.messageId = nextMessageIdForSession; // Check if this entry (same session, same *recalculated* messageId, same content) might already exist // This is a stricter check for true duplicates if multiple instances try to log the exact same thing // at the exact same calculated messageId slot. const entryExists = currentLogsOnDisk.some( (e) => e.sessionId === entryToAppend.sessionId && e.messageId === entryToAppend.messageId && e.timestamp === entryToAppend.timestamp && // Timestamps are good for distinguishing e.message === entryToAppend.message, ); if (entryExists) { console.debug( `Duplicate log entry detected and skipped: session ${entryToAppend.sessionId}, messageId ${entryToAppend.messageId}`, ); this.logs = currentLogsOnDisk; // Ensure in-memory is synced with disk return null; // Indicate that no new entry was actually added } currentLogsOnDisk.push(entryToAppend); try { await fs.writeFile( this.logFilePath, JSON.stringify(currentLogsOnDisk, null, 2), 'utf-8', ); this.logs = currentLogsOnDisk; return entryToAppend; // Return the successfully appended entry } catch (error) { console.debug('Error writing to log file:', error); throw error; } } async getPreviousUserMessages(): Promise { if (!this.initialized) return []; return this.logs .filter((entry) => entry.type === MessageSenderType.USER) .sort((a, b) => { if (b.sessionId !== a.sessionId) return b.sessionId - a.sessionId; const dateA = new Date(a.timestamp).getTime(); const dateB = new Date(b.timestamp).getTime(); if (dateB !== dateA) return dateB - dateA; return b.messageId - a.messageId; }) .map((entry) => entry.message); } async logMessage(type: MessageSenderType, message: string): Promise { if (!this.initialized || this.sessionId === undefined) { console.debug( 'Logger not initialized or session ID missing. Cannot log message.', ); return; } // The messageId used here is the instance's idea of the next ID. // _updateLogFile will verify and potentially recalculate based on the file's actual state. const newEntryObject: LogEntry = { sessionId: this.sessionId, messageId: this.messageId, // This will be recalculated in _updateLogFile type, message, timestamp: new Date().toISOString(), }; try { const writtenEntry = await this._updateLogFile(newEntryObject); if (writtenEntry) { // If an entry was actually written (not a duplicate skip), // then this instance can increment its idea of the next messageId for this session. this.messageId = writtenEntry.messageId + 1; } } catch (_error) { // Error already logged by _updateLogFile or _readLogFile } } close(): void { this.initialized = false; this.logFilePath = undefined; this.logs = []; this.sessionId = undefined; this.messageId = 0; } }