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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
|
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { Content } from '@google/genai';
import { Storage } from '../config/storage.js';
const LOG_FILE_NAME = 'logs.json';
export enum MessageSenderType {
USER = 'user',
}
export interface LogEntry {
sessionId: string;
messageId: number;
timestamp: string;
type: MessageSenderType;
message: string;
}
// This regex matches any character that is NOT a letter (a-z, A-Z),
// a number (0-9), a hyphen (-), an underscore (_), or a dot (.).
/**
* Encodes a string to be safe for use as a filename.
*
* It replaces any characters that are not alphanumeric or one of `_`, `-`, `.`
* with a URL-like percent-encoding (`%` followed by the 2-digit hex code).
*
* @param str The input string to encode.
* @returns The encoded, filename-safe string.
*/
export function encodeTagName(str: string): string {
return encodeURIComponent(str);
}
/**
* Decodes a string that was encoded with the `encode` function.
*
* It finds any percent-encoded characters and converts them back to their
* original representation.
*
* @param str The encoded string to decode.
* @returns The decoded, original string.
*/
export function decodeTagName(str: string): string {
try {
return decodeURIComponent(str);
} catch (_e) {
// Fallback for old, potentially malformed encoding
return str.replace(/%([0-9A-F]{2})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16)),
);
}
}
export class Logger {
private geminiDir: string | undefined;
private logFilePath: string | undefined;
private sessionId: string | 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
private regexLogFile: fs.FileHandle | undefined;
constructor(
sessionId: string,
private readonly storage: Storage,
) {
this.sessionId = sessionId;
}
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 === 'string' &&
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.geminiDir = this.storage.getProjectTempDir();
this.logFilePath = path.join(this.geminiDir, LOG_FILE_NAME);
try {
await fs.mkdir(this.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;
this.regexLogFile = await fs.open('/tmp/regex.log', 'a');
} 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) => {
const dateA = new Date(a.timestamp).getTime();
const dateB = new Date(b.timestamp).getTime();
return dateB - dateA;
})
.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;
if (this.regexLogFile) {
const logString = `[${writtenEntry.timestamp}] [${writtenEntry.type}] ${writtenEntry.message}\n`;
await this.regexLogFile.write(logString);
}
}
} catch (_error) {
// Error already logged by _updateLogFile or _readLogFile
}
}
private _checkpointPath(tag: string): string {
if (!tag.length) {
throw new Error('No checkpoint tag specified.');
}
if (!this.geminiDir) {
throw new Error('Checkpoint file path not set.');
}
// Encode the tag to handle all special characters safely.
const encodedTag = encodeTagName(tag);
return path.join(this.geminiDir, `checkpoint-${encodedTag}.json`);
}
private async _getCheckpointPath(tag: string): Promise<string> {
// 1. Check for the new encoded path first.
const newPath = this._checkpointPath(tag);
try {
await fs.access(newPath);
return newPath; // Found it, use the new path.
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
throw error; // A real error occurred, rethrow it.
}
// It was not found, so we'll check the old path next.
}
// 2. Fallback for backward compatibility: check for the old raw path.
const oldPath = path.join(this.geminiDir!, `checkpoint-${tag}.json`);
try {
await fs.access(oldPath);
return oldPath; // Found it, use the old path.
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
throw error; // A real error occurred, rethrow it.
}
}
// 3. If neither path exists, return the new encoded path as the canonical one.
return newPath;
}
async saveCheckpoint(conversation: Content[], tag: string): Promise<void> {
if (!this.initialized) {
console.error(
'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.',
);
return;
}
// Always save with the new encoded path.
const path = this._checkpointPath(tag);
try {
await fs.writeFile(path, JSON.stringify(conversation, null, 2), 'utf-8');
} catch (error) {
console.error('Error writing to checkpoint file:', error);
}
}
async loadCheckpoint(tag: string): Promise<Content[]> {
if (!this.initialized) {
console.error(
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
);
return [];
}
const path = await this._getCheckpointPath(tag);
try {
const fileContent = await fs.readFile(path, 'utf-8');
const parsedContent = JSON.parse(fileContent);
if (!Array.isArray(parsedContent)) {
console.warn(
`Checkpoint file at ${path} is not a valid JSON array. Returning empty checkpoint.`,
);
return [];
}
return parsedContent as Content[];
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
// This is okay, it just means the checkpoint doesn't exist in either format.
return [];
}
console.error(`Failed to read or parse checkpoint file ${path}:`, error);
return [];
}
}
async deleteCheckpoint(tag: string): Promise<boolean> {
if (!this.initialized || !this.geminiDir) {
console.error(
'Logger not initialized or checkpoint file path not set. Cannot delete checkpoint.',
);
return false;
}
let deletedSomething = false;
// 1. Attempt to delete the new encoded path.
const newPath = this._checkpointPath(tag);
try {
await fs.unlink(newPath);
deletedSomething = true;
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
console.error(`Failed to delete checkpoint file ${newPath}:`, error);
throw error; // Rethrow unexpected errors
}
// It's okay if it doesn't exist.
}
// 2. Attempt to delete the old raw path for backward compatibility.
const oldPath = path.join(this.geminiDir!, `checkpoint-${tag}.json`);
if (newPath !== oldPath) {
try {
await fs.unlink(oldPath);
deletedSomething = true;
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
console.error(`Failed to delete checkpoint file ${oldPath}:`, error);
throw error; // Rethrow unexpected errors
}
// It's okay if it doesn't exist.
}
}
return deletedSomething;
}
async checkpointExists(tag: string): Promise<boolean> {
if (!this.initialized) {
throw new Error(
'Logger not initialized. Cannot check for checkpoint existence.',
);
}
let filePath: string | undefined;
try {
filePath = await this._getCheckpointPath(tag);
// We need to check for existence again, because _getCheckpointPath
// returns a canonical path even if it doesn't exist yet.
await fs.access(filePath);
return true;
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
return false; // It truly doesn't exist in either format.
}
// A different error occurred.
console.error(
`Failed to check checkpoint existence for ${
filePath ?? `path for tag "${tag}"`
}:`,
error,
);
throw error;
}
}
close(): void {
if (this.regexLogFile) {
this.regexLogFile.close();
}
this.initialized = false;
this.logFilePath = undefined;
this.logs = [];
this.sessionId = undefined;
this.messageId = 0;
}
}
|