summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts75
-rw-r--r--packages/cli/src/ui/types.ts2
-rw-r--r--packages/core/src/core/logger.test.ts67
-rw-r--r--packages/core/src/core/logger.ts56
4 files changed, 196 insertions, 4 deletions
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index 9e82b6cf..c468a444 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -11,10 +11,11 @@ import process from 'node:process';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import {
Config,
+ Logger,
+ MCPDiscoveryState,
MCPServerStatus,
- getMCPServerStatus,
getMCPDiscoveryState,
- MCPDiscoveryState,
+ getMCPServerStatus,
} from '@gemini-cli/core';
import { Message, MessageType, HistoryItemWithoutId } from '../types.js';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -488,6 +489,76 @@ Add any other context about the problem here.
},
},
{
+ name: 'save',
+ description: 'save conversation checkpoint',
+ action: async (_mainCommand, _subCommand, _args) => {
+ const logger = new Logger();
+ await logger.initialize();
+ const chat = await config?.getGeminiClient()?.getChat();
+ const history = chat?.getHistory() || [];
+ if (history.length > 0) {
+ logger.saveCheckpoint(chat?.getHistory() || []);
+ } else {
+ addMessage({
+ type: MessageType.INFO,
+ content: 'No conversation found to save.',
+ timestamp: new Date(),
+ });
+ return;
+ }
+ },
+ },
+ {
+ name: 'resume',
+ description: 'resume from last conversation checkpoint',
+ action: async (_mainCommand, _subCommand, _args) => {
+ const logger = new Logger();
+ await logger.initialize();
+ const conversation = await logger.loadCheckpoint();
+ if (conversation.length === 0) {
+ addMessage({
+ type: MessageType.INFO,
+ content: 'No saved conversation found.',
+ timestamp: new Date(),
+ });
+ return;
+ }
+ const chat = await config?.getGeminiClient()?.getChat();
+ clearItems();
+ let i = 0;
+ const rolemap: { [key: string]: MessageType } = {
+ user: MessageType.USER,
+ model: MessageType.GEMINI,
+ };
+ for (const item of conversation) {
+ i += 1;
+ const text =
+ item.parts
+ ?.filter((m) => !!m.text)
+ .map((m) => m.text)
+ .join('') || '';
+ if (i <= 2) {
+ // Skip system prompt back and forth.
+ continue;
+ }
+ if (!text) {
+ // Parsing Part[] back to various non-text output not yet implemented.
+ continue;
+ }
+ addItem(
+ {
+ type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
+ text,
+ } as HistoryItemWithoutId,
+ i,
+ );
+ chat?.addHistory(item);
+ }
+ console.clear();
+ refreshStatic();
+ },
+ },
+ {
name: 'quit',
altName: 'exit',
description: 'exit the cli',
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 559a30a3..5fae1568 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -131,7 +131,7 @@ export enum MessageType {
USER = 'user',
ABOUT = 'about',
STATS = 'stats',
- // Add GEMINI if needed by other commands
+ GEMINI = 'gemini',
}
// Simplified message structure for internal feedback
diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts
index 2663a6be..f1d0e4a5 100644
--- a/packages/core/src/core/logger.test.ts
+++ b/packages/core/src/core/logger.test.ts
@@ -16,10 +16,17 @@ import {
import { Logger, MessageSenderType, LogEntry } from './logger.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
+import { Content } from '@google/genai';
const GEMINI_DIR = '.gemini';
const LOG_FILE_NAME = 'logs.json';
+const CHECKPOINT_FILE_NAME = 'checkpoint.json';
const TEST_LOG_FILE_PATH = path.join(process.cwd(), GEMINI_DIR, LOG_FILE_NAME);
+const TEST_CHECKPOINT_FILE_PATH = path.join(
+ process.cwd(),
+ GEMINI_DIR,
+ CHECKPOINT_FILE_NAME,
+);
async function cleanupLogFile() {
try {
@@ -30,10 +37,21 @@ async function cleanupLogFile() {
}
}
try {
+ await fs.unlink(TEST_CHECKPOINT_FILE_PATH);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ // Other errors during unlink are ignored for cleanup purposes
+ }
+ }
+ try {
const geminiDirPath = path.join(process.cwd(), GEMINI_DIR);
const dirContents = await fs.readdir(geminiDirPath);
for (const file of dirContents) {
- if (file.startsWith(LOG_FILE_NAME + '.') && file.endsWith('.bak')) {
+ if (
+ (file.startsWith(LOG_FILE_NAME + '.') ||
+ file.startsWith(CHECKPOINT_FILE_NAME + '.')) &&
+ file.endsWith('.bak')
+ ) {
try {
await fs.unlink(path.join(geminiDirPath, file));
} catch (_e) {
@@ -408,6 +426,53 @@ describe('Logger', () => {
});
});
+ describe('loadCheckpoint', () => {
+ it('should load and parse a valid checkpoint file', async () => {
+ const conversation: Content[] = [
+ { role: 'user', parts: [{ text: 'Hello' }] },
+ { role: 'model', parts: [{ text: 'Hi there' }] },
+ ];
+ await fs.writeFile(
+ TEST_CHECKPOINT_FILE_PATH,
+ JSON.stringify(conversation),
+ );
+ const loadedCheckpoint = await logger.loadCheckpoint();
+ expect(loadedCheckpoint).toEqual(conversation);
+ });
+
+ it('should return an empty array if the checkpoint file does not exist', async () => {
+ const loadedCheckpoint = await logger.loadCheckpoint();
+ expect(loadedCheckpoint).toEqual([]);
+ });
+
+ it('should return an empty array if the file contains invalid JSON', async () => {
+ await fs.writeFile(TEST_CHECKPOINT_FILE_PATH, 'invalid json');
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ const loadedCheckpoint = await logger.loadCheckpoint();
+ expect(loadedCheckpoint).toEqual([]);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to read or parse checkpoint file'),
+ expect.any(SyntaxError),
+ );
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should return an empty array if logger is not initialized', async () => {
+ const uninitializedLogger = new Logger();
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ const loadedCheckpoint = await uninitializedLogger.loadCheckpoint();
+ expect(loadedCheckpoint).toEqual([]);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
+ );
+ consoleErrorSpy.mockRestore();
+ });
+ });
+
describe('close', () => {
it('should reset logger state', async () => {
await logger.logMessage(MessageSenderType.USER, 'A message');
diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts
index feb16944..9026dc36 100644
--- a/packages/core/src/core/logger.ts
+++ b/packages/core/src/core/logger.ts
@@ -6,9 +6,11 @@
import path from 'node:path';
import { promises as fs } from 'node:fs';
+import { Content } from '@google/genai';
const GEMINI_DIR = '.gemini';
const LOG_FILE_NAME = 'logs.json';
+const CHECKPOINT_FILE_NAME = 'checkpoint.json';
export enum MessageSenderType {
USER = 'user',
@@ -24,6 +26,7 @@ export interface LogEntry {
export class Logger {
private logFilePath: string | undefined;
+ private checkpointFilePath: string | undefined;
private sessionId: number | undefined;
private messageId = 0; // Instance-specific counter for the next messageId
private initialized = false;
@@ -92,6 +95,7 @@ export class Logger {
this.sessionId = Math.floor(Date.now() / 1000);
const geminiDir = path.resolve(process.cwd(), GEMINI_DIR);
this.logFilePath = path.join(geminiDir, LOG_FILE_NAME);
+ this.checkpointFilePath = path.join(geminiDir, CHECKPOINT_FILE_NAME);
try {
await fs.mkdir(geminiDir, { recursive: true });
@@ -229,9 +233,61 @@ export class Logger {
}
}
+ async saveCheckpoint(conversation: Content[]): Promise<void> {
+ if (!this.initialized || !this.checkpointFilePath) {
+ console.error(
+ 'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.',
+ );
+ return;
+ }
+
+ try {
+ await fs.writeFile(
+ this.checkpointFilePath,
+ JSON.stringify(conversation, null),
+ 'utf-8',
+ );
+ } catch (error) {
+ console.error('Error writing to checkpoint file:', error);
+ }
+ }
+
+ async loadCheckpoint(): Promise<Content[]> {
+ if (!this.initialized || !this.checkpointFilePath) {
+ console.error(
+ 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
+ );
+ return [];
+ }
+
+ try {
+ const fileContent = await fs.readFile(this.checkpointFilePath, 'utf-8');
+ const parsedContent = JSON.parse(fileContent);
+ if (!Array.isArray(parsedContent)) {
+ console.warn(
+ `Checkpoint file at ${this.checkpointFilePath} 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') {
+ // File doesn't exist, which is fine. Return empty array.
+ return [];
+ }
+ console.error(
+ `Failed to read or parse checkpoint file ${this.checkpointFilePath}:`,
+ error,
+ );
+ return [];
+ }
+ }
+
close(): void {
this.initialized = false;
this.logFilePath = undefined;
+ this.checkpointFilePath = undefined;
this.logs = [];
this.sessionId = undefined;
this.messageId = 0;