summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/cli/src/ui/App.tsx43
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts9
-rw-r--r--packages/cli/src/ui/hooks/useLogger.ts32
-rw-r--r--packages/server/package.json3
-rw-r--r--packages/server/src/core/logger.test.ts197
-rw-r--r--packages/server/src/core/logger.ts131
-rw-r--r--packages/server/src/index.ts1
7 files changed, 397 insertions, 19 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 7f16f4e5..88b95481 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -20,7 +20,6 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js';
import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
-import { type Config } from '@gemini-code/server';
import { Colors } from './colors.js';
import { Help } from './components/Help.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
@@ -29,9 +28,10 @@ import { Tips } from './components/Tips.js';
import { ConsoleOutput } from './components/ConsolePatcher.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
import { useHistory } from './hooks/useHistoryManager.js';
-import process from 'node:process'; // For performMemoryRefresh
-import { MessageType } from './types.js'; // For performMemoryRefresh
-import { getErrorMessage } from '@gemini-code/server'; // For performMemoryRefresh
+import { useLogger } from './hooks/useLogger.js';
+import process from 'node:process';
+import { MessageType } from './types.js';
+import { getErrorMessage, type Config } from '@gemini-code/server';
interface AppProps {
config: Config;
@@ -157,18 +157,29 @@ export const App = ({
[submitQuery],
);
- const userMessages = useMemo(
- () =>
- history
- .filter(
- (item): item is HistoryItem & { type: 'user'; text: string } =>
- item.type === 'user' &&
- typeof item.text === 'string' &&
- item.text.trim() !== '',
- )
- .map((item) => item.text),
- [history],
- );
+ const logger = useLogger();
+ const [userMessages, setUserMessages] = useState<string[]>([]);
+
+ useEffect(() => {
+ const fetchUserMessages = async () => {
+ const pastMessages = (await logger?.getPreviousUserMessages()) || [];
+ if (pastMessages.length > 0) {
+ setUserMessages(pastMessages.reverse());
+ } else {
+ setUserMessages(
+ history
+ .filter(
+ (item): item is HistoryItem & { type: 'user'; text: string } =>
+ item.type === 'user' &&
+ typeof item.text === 'string' &&
+ item.text.trim() !== '',
+ )
+ .map((item) => item.text),
+ );
+ }
+ };
+ fetchUserMessages();
+ }, [history, logger]);
const isInputActive = streamingState === StreamingState.Idle && !initError;
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 3ca4b03a..1cd2438c 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -18,13 +18,14 @@ import {
getErrorMessage,
isNodeError,
Config,
+ MessageSenderType,
+ ServerToolCallConfirmationDetails,
ToolCallConfirmationDetails,
ToolCallResponseInfo,
- ServerToolCallConfirmationDetails,
ToolConfirmationOutcome,
- ToolResultDisplay,
ToolEditConfirmationDetails,
ToolExecuteConfirmationDetails,
+ ToolResultDisplay,
partListUnionToString,
} from '@gemini-code/server';
import { type Chat, type PartListUnion, type Part } from '@google/genai';
@@ -42,6 +43,7 @@ import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { useStateAndRef } from './useStateAndRef.js';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
+import { useLogger } from './useLogger.js';
enum StreamProcessingStatus {
Completed,
@@ -71,6 +73,7 @@ export const useGeminiStream = (
const [isResponding, setIsResponding] = useState<boolean>(false);
const [pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
+ const logger = useLogger();
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
@@ -117,6 +120,7 @@ export const useGeminiStream = (
if (typeof query === 'string') {
const trimmedQuery = query.trim();
onDebugMessage(`User query: '${trimmedQuery}'`);
+ await logger?.logMessage(MessageSenderType.USER, trimmedQuery);
// Handle UI-only commands first
if (handleSlashCommand(trimmedQuery)) {
@@ -616,6 +620,7 @@ export const useGeminiStream = (
onDebugMessage,
refreshStatic,
setInitError,
+ logger,
],
);
diff --git a/packages/cli/src/ui/hooks/useLogger.ts b/packages/cli/src/ui/hooks/useLogger.ts
new file mode 100644
index 00000000..080c3da9
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useLogger.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useEffect } from 'react';
+import { Logger } from '@gemini-code/server';
+
+/**
+ * Hook to manage the logger instance.
+ */
+export const useLogger = () => {
+ const [logger, setLogger] = useState<Logger | null>(null);
+
+ useEffect(() => {
+ const newLogger = new Logger();
+ /**
+ * Start async initialization, no need to await. Using await slows down the
+ * time from launch to see the gemini-cli prompt and it's better to not save
+ * messages than for the cli to hanging waiting for the logger to loading.
+ */
+ newLogger
+ .initialize()
+ .then(() => {
+ setLogger(newLogger);
+ })
+ .catch(() => {});
+ }, []);
+
+ return logger;
+};
diff --git a/packages/server/package.json b/packages/server/package.json
index c90a7169..37ba5179 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -25,7 +25,8 @@
"@modelcontextprotocol/sdk": "^1.11.0",
"diff": "^7.0.0",
"dotenv": "^16.4.7",
- "fast-glob": "^3.3.3"
+ "fast-glob": "^3.3.3",
+ "sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/diff": "^7.0.2",
diff --git a/packages/server/src/core/logger.test.ts b/packages/server/src/core/logger.test.ts
new file mode 100644
index 00000000..9b4f4555
--- /dev/null
+++ b/packages/server/src/core/logger.test.ts
@@ -0,0 +1,197 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { Logger, MessageSenderType } from './logger.js';
+
+// Mocks
+const mockDb = {
+ exec: vi.fn((_sql, callback) => callback?.(null)),
+ all: vi.fn((_sql, _params, callback) => callback?.(null, [])),
+ run: vi.fn((_sql, _params, callback) => callback?.(null)),
+ close: vi.fn((callback) => callback?.(null)),
+};
+
+vi.mock('sqlite3', () => ({
+ Database: vi.fn((_dbPath, _options, callback) => {
+ process.nextTick(() => callback?.(null));
+ return mockDb;
+ }),
+ default: {
+ Database: vi.fn((_dbPath, _options, callback) => {
+ process.nextTick(() => callback?.(null));
+ return mockDb;
+ }),
+ },
+}));
+
+describe('Logger', () => {
+ let logger: Logger;
+
+ beforeEach(async () => {
+ vi.resetAllMocks();
+
+ // Get a new instance for each test to ensure isolation,
+ logger = new Logger();
+ // We need to wait for the async initialize to complete
+ await logger.initialize().catch((err) => {
+ console.error('Error initializing logger:', err);
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ logger.close(); // Close the database connection after each test
+ });
+
+ describe('initialize', () => {
+ it('should execute create tables if not exists', async () => {
+ expect(mockDb.exec).toHaveBeenCalledWith(
+ expect.stringMatching(/CREATE TABLE IF NOT EXISTS messages/),
+ expect.any(Function),
+ );
+ });
+
+ it('should be idempotent', async () => {
+ mockDb.exec.mockClear();
+
+ await logger.initialize(); // Second call
+
+ expect(mockDb.exec).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('logMessage', () => {
+ it('should insert a message into the database', async () => {
+ const type = MessageSenderType.USER;
+ const message = 'Hello, world!';
+ await logger.logMessage(type, message);
+ expect(mockDb.run).toHaveBeenCalledWith(
+ "INSERT INTO messages (session_id, message_id, type, message, timestamp) VALUES (?, ?, ?, ?, datetime('now'))",
+ [expect.any(Number), 0, type, message], // sessionId, messageId, type, message
+ expect.any(Function),
+ );
+ });
+
+ it('should increment messageId for subsequent messages', async () => {
+ await logger.logMessage(MessageSenderType.USER, 'First message');
+ expect(mockDb.run).toHaveBeenCalledWith(
+ expect.any(String),
+ [expect.any(Number), 0, MessageSenderType.USER, 'First message'],
+ expect.any(Function),
+ );
+ await logger.logMessage(MessageSenderType.USER, 'Second message');
+ expect(mockDb.run).toHaveBeenCalledWith(
+ expect.any(String),
+ [expect.any(Number), 1, MessageSenderType.USER, 'Second message'], // messageId is now 1
+ expect.any(Function),
+ );
+ });
+
+ it('should handle database not initialized', async () => {
+ const uninitializedLogger = new Logger();
+ // uninitializedLogger.initialize() is not called
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ await uninitializedLogger.logMessage(MessageSenderType.USER, 'test');
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Database not initialized.');
+ expect(mockDb.run).not.toHaveBeenCalled();
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should handle error during db.run', async () => {
+ const error = new Error('db.run failed');
+ mockDb.run.mockImplementationOnce(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (_sql: any, _params: any, callback: any) => callback?.(error),
+ );
+
+ await expect(
+ logger.logMessage(MessageSenderType.USER, 'test'),
+ ).rejects.toThrow('db.run failed');
+ });
+ });
+
+ describe('getPreviousUserMessages', () => {
+ it('should query the database for messages', async () => {
+ mockDb.all.mockImplementationOnce(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (_sql: any, params: any, callback: any) =>
+ callback?.(null, [{ message: 'msg1' }, { message: 'msg2' }]),
+ );
+
+ const messages = await logger.getPreviousUserMessages();
+
+ expect(mockDb.all).toHaveBeenCalledWith(
+ expect.stringMatching(/SELECT message FROM messages/),
+ [],
+ expect.any(Function),
+ );
+ expect(messages).toEqual(['msg1', 'msg2']);
+ });
+
+ it('should handle database not initialized', async () => {
+ const uninitializedLogger = new Logger();
+ // uninitializedLogger.initialize() is not called
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ const messages = await uninitializedLogger.getPreviousUserMessages();
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Database not initialized.');
+ expect(messages).toEqual([]);
+ expect(mockDb.all).not.toHaveBeenCalled();
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should handle error during db.all', async () => {
+ const error = new Error('db.all failed');
+ mockDb.all.mockImplementationOnce(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (_sql: any, _params: any, callback: any) => callback?.(error, []),
+ );
+
+ await expect(logger.getPreviousUserMessages()).rejects.toThrow(
+ 'db.all failed',
+ );
+ });
+ });
+
+ describe('close', () => {
+ it('should close the database connection', () => {
+ logger.close();
+ expect(mockDb.close).toHaveBeenCalled();
+ });
+
+ it('should handle database not initialized', () => {
+ const uninitializedLogger = new Logger();
+ // uninitializedLogger.initialize() is not called
+ uninitializedLogger.close();
+ expect(() => uninitializedLogger.close()).not.toThrow();
+ });
+
+ it('should handle error during db.close', () => {
+ const error = new Error('db.close failed');
+ mockDb.close.mockImplementationOnce((callback: (error: Error) => void) =>
+ callback?.(error),
+ );
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ logger.close();
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error closing database:',
+ error.message,
+ );
+ consoleErrorSpy.mockRestore();
+ });
+ });
+});
diff --git a/packages/server/src/core/logger.ts b/packages/server/src/core/logger.ts
new file mode 100644
index 00000000..d12d4240
--- /dev/null
+++ b/packages/server/src/core/logger.ts
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import path from 'node:path';
+import sqlite3 from 'sqlite3';
+import { promises as fs } from 'node:fs';
+
+const GEMINI_DIR = '.gemini';
+const DB_NAME = 'logs.db';
+const CREATE_TABLE_SQL = `
+CREATE TABLE IF NOT EXISTS messages (
+ session_id INTEGER,
+ message_id INTEGER,
+ timestamp TEXT,
+ type TEXT,
+ message TEXT
+);`;
+
+export enum MessageSenderType {
+ USER = 'user',
+}
+
+export class Logger {
+ private db: sqlite3.Database | undefined;
+ private sessionId: number | undefined;
+ private messageId: number | undefined;
+
+ constructor() {}
+
+ async initialize(): Promise<void> {
+ if (this.db) {
+ return;
+ }
+
+ this.sessionId = Math.floor(Date.now() / 1000);
+ this.messageId = 0;
+
+ // Could be cleaner if our sqlite package supported promises.
+ return new Promise((resolve, reject) => {
+ const DB_DIR = path.resolve(process.cwd(), GEMINI_DIR);
+ const DB_PATH = path.join(DB_DIR, DB_NAME);
+ fs.mkdir(DB_DIR, { recursive: true })
+ .then(() => {
+ this.db = new sqlite3.Database(
+ DB_PATH,
+ sqlite3.OPEN_READWRITE |
+ sqlite3.OPEN_CREATE |
+ sqlite3.OPEN_FULLMUTEX,
+ (err: Error | null) => {
+ if (err) {
+ reject(err);
+ }
+
+ // Read and execute the SQL script in create_tables.sql
+ this.db?.exec(CREATE_TABLE_SQL, (err: Error | null) => {
+ if (err) {
+ this.db?.close();
+ reject(err);
+ }
+ resolve();
+ });
+ },
+ );
+ })
+ .catch(reject);
+ });
+ }
+
+ /**
+ * Get list of previous user inputs sorted most recent first.
+ * @returns list of messages.
+ */
+ async getPreviousUserMessages(): Promise<string[]> {
+ if (!this.db) {
+ console.error('Database not initialized.');
+ return [];
+ }
+
+ return new Promise((resolve, reject) => {
+ // Most recent messages first
+ const query = `SELECT message FROM messages
+ WHERE type = '${MessageSenderType.USER}'
+ ORDER BY session_id DESC, message_id DESC`;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ this.db!.all(query, [], (err: Error | null, rows: any[]) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(rows.map((row) => row.message));
+ }
+ });
+ });
+ }
+
+ async logMessage(type: MessageSenderType, message: string): Promise<void> {
+ if (!this.db) {
+ console.error('Database not initialized.');
+ return;
+ }
+
+ return new Promise((resolve, reject) => {
+ const query = `INSERT INTO messages (session_id, message_id, type, message, timestamp) VALUES (?, ?, ?, ?, datetime('now'))`;
+ this.messageId = this.messageId! + 1;
+ this.db!.run(
+ query,
+ [this.sessionId || 0, this.messageId - 1, type, message],
+ (err: Error | null) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve();
+ }
+ },
+ );
+ });
+ }
+
+ close(): void {
+ if (this.db) {
+ this.db.close((err: Error | null) => {
+ if (err) {
+ console.error('Error closing database:', err.message);
+ }
+ });
+ this.db = undefined;
+ }
+ }
+}
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index c90ef2fd..788fe6e4 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -9,6 +9,7 @@ export * from './config/config.js';
// Export Core Logic
export * from './core/client.js';
+export * from './core/logger.js';
export * from './core/prompts.js';
export * from './core/turn.js';
export * from './core/geminiRequest.js';