1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
|
/**
* @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<LogEntry[]> {
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<void> {
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<void> {
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<LogEntry | null> {
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<string[]> {
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<void> {
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;
}
}
|