From 7f9d59a7ff3484c83c54c4c4836a4629fc5210ed Mon Sep 17 00:00:00 2001 From: Daniel Espendiller Date: Wed, 11 Feb 2026 19:47:02 +0100 Subject: [PATCH] #1 Add Gemini adapter test fixtures, implementation guide, and tests --- README.md | 1 + .../__tests__/fixtures/assistant_simple.json | 24 + .../__tests__/fixtures/error_message.json | 14 + .../__tests__/fixtures/info_message.json | 14 + .../fixtures/mixed_conversation.json | 83 +++ .../__tests__/fixtures/user_message.json | 14 + .../__tests__/fixtures/with_file_diff.json | 140 +++++ src/adapters/gemini/__tests__/gemini.test.ts | 505 ++++++++++++++++++ src/adapters/gemini/adapter.ts | 41 ++ src/adapters/gemini/index.ts | 431 +++++++++++++++ src/adapters/importer.ts | 2 + src/public/assets/icons/providers/gemini.svg | 10 + src/types.ts | 3 +- 13 files changed, 1281 insertions(+), 1 deletion(-) create mode 100644 src/adapters/gemini/__tests__/fixtures/assistant_simple.json create mode 100644 src/adapters/gemini/__tests__/fixtures/error_message.json create mode 100644 src/adapters/gemini/__tests__/fixtures/info_message.json create mode 100644 src/adapters/gemini/__tests__/fixtures/mixed_conversation.json create mode 100644 src/adapters/gemini/__tests__/fixtures/user_message.json create mode 100644 src/adapters/gemini/__tests__/fixtures/with_file_diff.json create mode 100644 src/adapters/gemini/__tests__/gemini.test.ts create mode 100644 src/adapters/gemini/adapter.ts create mode 100644 src/adapters/gemini/index.ts create mode 100644 src/public/assets/icons/providers/gemini.svg diff --git a/README.md b/README.md index d2a73c6..da6c8f7 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Import your AI chat sessions into a local database, then browse and search them | **Amp** | `~/.local/share/amp/threads/` | | **Junie** | `~/.junie/sessions/` | | **Kilocode** | `~/.kilocode/cli/` | +| **Gemini** | `~/.gemini/tmp/{project}/chats/` | ## Tech Stack diff --git a/src/adapters/gemini/__tests__/fixtures/assistant_simple.json b/src/adapters/gemini/__tests__/fixtures/assistant_simple.json new file mode 100644 index 0000000..28ef4d6 --- /dev/null +++ b/src/adapters/gemini/__tests__/fixtures/assistant_simple.json @@ -0,0 +1,24 @@ +{ + "sessionId": "test-gemini-simple-123", + "projectHash": "78e0c7326cf29ebec3d3e54ec35141c0af295bdb67fc76f00e79dc9cff73ba00", + "startTime": "2026-02-08T17:00:00.000Z", + "lastUpdated": "2026-02-08T17:01:00.000Z", + "messages": [ + { + "id": "msg-gemini-001", + "timestamp": "2026-02-08T17:01:00.000Z", + "type": "gemini", + "content": "This is a simple assistant response without thoughts or tool calls.", + "thoughts": [], + "tokens": { + "input": 100, + "output": 50, + "cached": 0, + "thoughts": 0, + "tool": 0, + "total": 150 + }, + "model": "gemini-3-flash-preview" + } + ] +} diff --git a/src/adapters/gemini/__tests__/fixtures/error_message.json b/src/adapters/gemini/__tests__/fixtures/error_message.json new file mode 100644 index 0000000..4793c5a --- /dev/null +++ b/src/adapters/gemini/__tests__/fixtures/error_message.json @@ -0,0 +1,14 @@ +{ + "sessionId": "test-error-123", + "projectHash": "78e0c7326cf29ebec3d3e54ec35141c0af295bdb67fc76f00e79dc9cff73ba00", + "startTime": "2026-02-08T17:00:00.000Z", + "lastUpdated": "2026-02-08T17:00:30.000Z", + "messages": [ + { + "id": "msg-error-001", + "timestamp": "2026-02-08T17:00:30.000Z", + "type": "error", + "content": "An error occurred while processing your request" + } + ] +} diff --git a/src/adapters/gemini/__tests__/fixtures/info_message.json b/src/adapters/gemini/__tests__/fixtures/info_message.json new file mode 100644 index 0000000..9acd836 --- /dev/null +++ b/src/adapters/gemini/__tests__/fixtures/info_message.json @@ -0,0 +1,14 @@ +{ + "sessionId": "test-info-123", + "projectHash": "78e0c7326cf29ebec3d3e54ec35141c0af295bdb67fc76f00e79dc9cff73ba00", + "startTime": "2026-02-08T17:00:00.000Z", + "lastUpdated": "2026-02-08T17:00:10.000Z", + "messages": [ + { + "id": "msg-info-001", + "timestamp": "2026-02-08T17:00:10.000Z", + "type": "info", + "content": "Gemini CLI update available! 0.25.1 → 0.27.3" + } + ] +} diff --git a/src/adapters/gemini/__tests__/fixtures/mixed_conversation.json b/src/adapters/gemini/__tests__/fixtures/mixed_conversation.json new file mode 100644 index 0000000..3aef378 --- /dev/null +++ b/src/adapters/gemini/__tests__/fixtures/mixed_conversation.json @@ -0,0 +1,83 @@ +{ + "sessionId": "26d30166-6e7c-4881-bd90-233138784e9d", + "projectHash": "78e0c7326cf29ebec3d3e54ec35141c0af295bdb67fc76f00e79dc9cff73ba00", + "startTime": "2026-02-08T17:31:42.123Z", + "lastUpdated": "2026-02-08T17:34:00.665Z", + "messages": [ + { + "id": "dd659bea-ce51-4962-892d-db3487116dcf", + "timestamp": "2026-02-08T17:31:42.123Z", + "type": "error", + "content": "Automatic update failed. Please try updating manually" + }, + { + "id": "4632e760-f590-4410-b0c7-fcbfb5ff3d34", + "timestamp": "2026-02-08T17:33:08.759Z", + "type": "user", + "content": "welches project bin ich?" + }, + { + "id": "76009b7d-3590-43f2-9b6a-9f02f19fef03", + "timestamp": "2026-02-08T17:33:43.688Z", + "type": "gemini", + "content": "I investigate the project's identity by examining `package.json` and `README.md`.", + "thoughts": [ + { + "subject": "Determining Project Identity", + "description": "I'm zeroing in on the project. My current focus is the working directory: `/home/daniel/projects/my-mega-memory`. Initial clues point towards a Node.js project.", + "timestamp": "2026-02-08T17:33:43.197Z" + } + ], + "tokens": { + "input": 8262, + "output": 47, + "cached": 0, + "thoughts": 102, + "tool": 0, + "total": 8411 + }, + "model": "gemini-3-flash-preview", + "toolCalls": [ + { + "id": "read_file-1770572023519-b9f3f344bf8a2", + "name": "read_file", + "args": { + "file_path": "package.json" + }, + "result": [ + { + "functionResponse": { + "id": "read_file-1770572023519-b9f3f344bf8a2", + "name": "read_file", + "response": { + "output": "{\"name\": \"my-mega-memory\"}" + } + } + } + ], + "status": "success", + "timestamp": "2026-02-08T17:33:43.742Z", + "resultDisplay": "", + "displayName": "ReadFile", + "description": "Reads and returns the content of a specified file" + } + ] + }, + { + "id": "e9dc2a09-495f-464e-9d8d-2f8f16629eee", + "timestamp": "2026-02-08T17:34:00.665Z", + "type": "gemini", + "content": "Du arbeitest am Projekt **My Mega Memory**.", + "thoughts": [], + "tokens": { + "input": 10485, + "output": 175, + "cached": 6625, + "thoughts": 0, + "tool": 0, + "total": 10660 + }, + "model": "gemini-3-flash-preview" + } + ] +} diff --git a/src/adapters/gemini/__tests__/fixtures/user_message.json b/src/adapters/gemini/__tests__/fixtures/user_message.json new file mode 100644 index 0000000..75e7264 --- /dev/null +++ b/src/adapters/gemini/__tests__/fixtures/user_message.json @@ -0,0 +1,14 @@ +{ + "sessionId": "test-user-simple-123", + "projectHash": "78e0c7326cf29ebec3d3e54ec35141c0af295bdb67fc76f00e79dc9cff73ba00", + "startTime": "2026-02-08T17:00:00.000Z", + "lastUpdated": "2026-02-08T17:01:00.000Z", + "messages": [ + { + "id": "msg-user-001", + "timestamp": "2026-02-08T17:00:00.000Z", + "type": "user", + "content": "Hello, this is a simple user message" + } + ] +} diff --git a/src/adapters/gemini/__tests__/fixtures/with_file_diff.json b/src/adapters/gemini/__tests__/fixtures/with_file_diff.json new file mode 100644 index 0000000..07b9b4e --- /dev/null +++ b/src/adapters/gemini/__tests__/fixtures/with_file_diff.json @@ -0,0 +1,140 @@ +{ + "sessionId": "1899196a-4467-45bb-98c2-382d29e32edc", + "projectHash": "78e0c7326cf29ebec3d3e54ec35141c0af295bdb67fc76f00e79dc9cff73ba00", + "startTime": "2026-02-11T17:39:50.911Z", + "lastUpdated": "2026-02-11T17:44:33.198Z", + "messages": [ + { + "id": "d8b98385-ef3b-4233-b90f-69f50a66c731", + "timestamp": "2026-02-11T17:39:50.911Z", + "type": "user", + "content": [ + { + "text": "hello world please read readme.md and create an simple hello file matching the project language" + } + ] + }, + { + "id": "6755075f-6ebd-4cce-bef9-cfda4d1d9bbe", + "timestamp": "2026-02-11T17:40:50.202Z", + "type": "gemini", + "content": "", + "toolCalls": [ + { + "id": "read_file-1770831650172-144884a402315", + "name": "read_file", + "args": { + "file_path": "README.md" + }, + "result": [ + { + "functionResponse": { + "id": "read_file-1770831650172-144884a402315", + "name": "read_file", + "response": { + "output": "# My Mega Memory\n\nAI Chat Session Importer and Viewer" + } + } + } + ], + "status": "success", + "timestamp": "2026-02-11T17:40:50.201Z", + "resultDisplay": "", + "displayName": "ReadFile" + } + ], + "thoughts": [ + { + "subject": "Starting Project Analysis", + "description": "I've begun by reading the project's documentation.", + "timestamp": "2026-02-11T17:40:50.049Z" + } + ], + "model": "gemini-3-flash-preview", + "tokens": { + "input": 8477, + "output": 13, + "cached": 0, + "thoughts": 39, + "tool": 0, + "total": 8529 + } + }, + { + "id": "7dd4db6a-3a0e-426c-8560-e05a27191b70", + "timestamp": "2026-02-11T17:42:18.950Z", + "type": "gemini", + "content": "", + "toolCalls": [ + { + "id": "write_file-1770831711720-1ec9ef3343b46", + "name": "write_file", + "args": { + "file_path": "src/hello.ts", + "content": "console.log(\"Hello, World!\");\n" + }, + "result": [ + { + "functionResponse": { + "id": "write_file-1770831711720-1ec9ef3343b46", + "name": "write_file", + "response": { + "output": "Successfully created and wrote to new file: /home/daniel/projects/my-mega-memory/src/hello.ts." + } + } + } + ], + "status": "success", + "timestamp": "2026-02-11T17:42:18.950Z", + "resultDisplay": { + "fileDiff": "Index: hello.ts\n===================================================================\n--- hello.ts\tOriginal\n+++ hello.ts\tWritten\n@@ -0,0 +1,1 @@\n+console.log(\"Hello, World!\");\n", + "fileName": "hello.ts", + "filePath": "/home/daniel/projects/my-mega-memory/src/hello.ts", + "originalContent": "", + "newContent": "console.log(\"Hello, World!\");\n", + "isNewFile": true + }, + "displayName": "WriteFile" + } + ], + "thoughts": [ + { + "subject": "Considering Initial Setup", + "description": "I've decided to start this project with a Node.js and TypeScript application.", + "timestamp": "2026-02-11T17:41:51.721Z" + } + ], + "model": "gemini-3-flash-preview", + "tokens": { + "input": 10207, + "output": 25, + "cached": 0, + "thoughts": 35, + "tool": 0, + "total": 10267 + } + }, + { + "id": "44dfac0f-1f5c-4bc2-b678-27c2e537a187", + "timestamp": "2026-02-11T17:44:33.198Z", + "type": "gemini", + "content": "The `README.md` has been reviewed, and a `src/hello.ts` file has been created and verified.\n\n```typescript\n// src/hello.ts\nconsole.log(\"Hello, World!\");\n```\n\nYou can run it with `npx ts-node src/hello.ts`.", + "thoughts": [ + { + "subject": "Creating a Basic Script", + "description": "I've successfully created and executed the `hello.ts` file.", + "timestamp": "2026-02-11T17:44:33.107Z" + } + ], + "model": "gemini-3-flash-preview", + "tokens": { + "input": 10932, + "output": 65, + "cached": 0, + "thoughts": 41, + "tool": 0, + "total": 11038 + } + } + ] +} diff --git a/src/adapters/gemini/__tests__/gemini.test.ts b/src/adapters/gemini/__tests__/gemini.test.ts new file mode 100644 index 0000000..b833863 --- /dev/null +++ b/src/adapters/gemini/__tests__/gemini.test.ts @@ -0,0 +1,505 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { GeminiSessionFinder, GeminiSessionParser } from '../index'; + +describe('GeminiSessionFinder', () => { + let finder: GeminiSessionFinder; + + beforeEach(() => { + finder = new GeminiSessionFinder(); + }); + + describe('getBaseDir', () => { + it('should return correct base directory path', () => { + const baseDir = (finder as any).baseDir; + const homeDir = require('os').homedir(); + expect(baseDir).toBe(path.join(homeDir, '.gemini', 'tmp')); + }); + }); + + describe('readProjectRoot', () => { + it('should read project path from .project_root file', () => { + // Create a temporary directory structure + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + const projectDir = path.join(tmpDir, 'my-mega-memory'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, '.project_root'), '/home/daniel/projects/my-mega-memory'); + + try { + // Temporarily override baseDir + (finder as any).baseDir = tmpDir; + + const projectPath = (finder as any).readProjectRoot('my-mega-memory'); + expect(projectPath).toBe('/home/daniel/projects/my-mega-memory'); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it('should return null when .project_root does not exist', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + + try { + (finder as any).baseDir = tmpDir; + + const projectPath = (finder as any).readProjectRoot('nonexistent'); + expect(projectPath).toBeNull(); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it('should return null when .project_root is empty', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + const projectDir = path.join(tmpDir, 'empty-project'); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, '.project_root'), ''); + + try { + (finder as any).baseDir = tmpDir; + + const projectPath = (finder as any).readProjectRoot('empty-project'); + expect(projectPath).toBeNull(); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + }); + + describe('extractProjectName', () => { + it('should extract basename from path', () => { + const name = (finder as any).extractProjectName('/home/daniel/projects/my-mega-memory'); + expect(name).toBe('my-mega-memory'); + }); + + it('should handle simple directory names', () => { + const name = (finder as any).extractProjectName('my-project'); + expect(name).toBe('my-project'); + }); + }); + + describe('listSessions', () => { + it('should find sessions in projects with .project_root', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + + // Create project with .project_root and chats + const projectDir = path.join(tmpDir, 'my-mega-memory'); + const chatsDir = path.join(projectDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, '.project_root'), '/home/user/my-mega-memory'); + + // Create a session file + const sessionData = { + sessionId: 'test-session-123', + projectHash: 'abc123', + startTime: '2026-02-11T17:00:00.000Z', + lastUpdated: '2026-02-11T17:01:00.000Z', + messages: [{ id: 'msg-1', timestamp: '2026-02-11T17:00:00.000Z', type: 'user', content: 'Hello' }] + }; + fs.writeFileSync(path.join(chatsDir, 'session-2026-02-11T17-00-test.json'), JSON.stringify(sessionData)); + + // Create project without .project_root (should be skipped) + const noRootDir = path.join(tmpDir, 'no-root-project'); + const noRootChatsDir = path.join(noRootDir, 'chats'); + fs.mkdirSync(noRootChatsDir, { recursive: true }); + fs.writeFileSync(path.join(noRootChatsDir, 'session-test.json'), JSON.stringify(sessionData)); + + try { + (finder as any).baseDir = tmpDir; + + const sessions = finder.listSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0].projectName).toBe('my-mega-memory'); + expect(sessions[0].projectPath).toBe('/home/user/my-mega-memory'); + expect(sessions[0].sessionId).toBe('test-session-123'); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it('should skip directories without .project_root', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + + // Create project without .project_root + const projectDir = path.join(tmpDir, 'hash-directory'); + const chatsDir = path.join(projectDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + + const sessionData = { + sessionId: 'test-session-456', + projectHash: 'def456', + startTime: '2026-02-11T17:00:00.000Z', + lastUpdated: '2026-02-11T17:01:00.000Z', + messages: [] + }; + fs.writeFileSync(path.join(chatsDir, 'session-test.json'), JSON.stringify(sessionData)); + + try { + (finder as any).baseDir = tmpDir; + + const sessions = finder.listSessions(); + expect(sessions).toHaveLength(0); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it('should sort sessions by updated date descending', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + + const projectDir = path.join(tmpDir, 'project'); + const chatsDir = path.join(projectDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, '.project_root'), '/home/user/project'); + + // Create older session + const oldSession = { + sessionId: 'old-session', + projectHash: 'abc', + startTime: '2026-02-11T10:00:00.000Z', + lastUpdated: '2026-02-11T10:00:00.000Z', + messages: [] + }; + fs.writeFileSync(path.join(chatsDir, 'session-old.json'), JSON.stringify(oldSession)); + + // Create newer session + const newSession = { + sessionId: 'new-session', + projectHash: 'abc', + startTime: '2026-02-11T12:00:00.000Z', + lastUpdated: '2026-02-11T12:00:00.000Z', + messages: [] + }; + fs.writeFileSync(path.join(chatsDir, 'session-new.json'), JSON.stringify(newSession)); + + try { + (finder as any).baseDir = tmpDir; + + const sessions = finder.listSessions(); + expect(sessions).toHaveLength(2); + expect(sessions[0].sessionId).toBe('new-session'); + expect(sessions[1].sessionId).toBe('old-session'); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + }); +}); + +describe('GeminiSessionParser', () => { + let parser: GeminiSessionParser; + const fixturesDir = path.join(__dirname, 'fixtures'); + + beforeEach(() => { + parser = new GeminiSessionParser(); + }); + + describe('parseFile', () => { + it('should return null for non-existent file', () => { + const result = parser.parseFile('/nonexistent/path/session.json'); + expect(result).toBeNull(); + }); + }); + + describe('parseContent', () => { + it('should parse user message', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'user_message.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session).not.toBeNull(); + expect(session.title).toBe('Hello, this is a simple user message'); + expect(session.messages).toHaveLength(1); + expect(session.messages[0].type).toBe('user'); + + const userMsg = session.messages[0] as { type: 'user'; content: Array<{ type: string; text?: string }> }; + expect(userMsg.content[0].type).toBe('text'); + if (userMsg.content[0].type === 'text') { + expect(userMsg.content[0].text).toBe('Hello, this is a simple user message'); + } + }); + + it('should parse error message', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'error_message.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session).not.toBeNull(); + expect(session.messages).toHaveLength(1); + expect(session.messages[0].type).toBe('info'); + + const errorMsg = session.messages[0] as { type: 'info'; title: string; content?: { type: string; text: string } }; + expect(errorMsg.title).toBe('error'); + expect(errorMsg.content?.text).toBe('An error occurred while processing your request'); + }); + + it('should parse info message', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'info_message.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session).not.toBeNull(); + expect(session.messages).toHaveLength(1); + expect(session.messages[0].type).toBe('info'); + + const infoMsg = session.messages[0] as { type: 'info'; title: string; content?: { type: string; text: string } }; + expect(infoMsg.title).toBe('info'); + expect(infoMsg.content?.text).toContain('Gemini CLI update'); + }); + + it('should parse simple assistant message', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'assistant_simple.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session).not.toBeNull(); + expect(session.messages).toHaveLength(1); + expect(session.messages[0].type).toBe('assistant_text'); + + const assistantMsg = session.messages[0] as { type: 'assistant_text'; content: Array<{ type: string; markdown?: string }> }; + expect(assistantMsg.content[0].type).toBe('markdown'); + if (assistantMsg.content[0].type === 'markdown') { + expect(assistantMsg.content[0].markdown).toBe('This is a simple assistant response without thoughts or tool calls.'); + } + }); + + it('should parse mixed conversation with user, gemini, and tool calls', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'mixed_conversation.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session).not.toBeNull(); + expect(session.messages.length).toBeGreaterThan(0); + + // Check metadata + expect(session.metadata).toBeDefined(); + expect(session.metadata?.models).toBeDefined(); + expect(session.metadata?.models.length).toBeGreaterThan(0); + expect(session.metadata?.models[0][0]).toBe('gemini-3-flash-preview'); + + // Should have user messages + const userMessages = session.messages.filter(m => m.type === 'user'); + expect(userMessages.length).toBeGreaterThan(0); + + // Should have thinking messages (from thoughts) + const thinkingMessages = session.messages.filter(m => m.type === 'assistant_thinking'); + expect(thinkingMessages.length).toBeGreaterThan(0); + + // Should have tool_use messages + const toolUseMessages = session.messages.filter(m => m.type === 'tool_use'); + expect(toolUseMessages.length).toBeGreaterThan(0); + + // Should have assistant_text messages + const assistantMessages = session.messages.filter(m => m.type === 'assistant_text'); + expect(assistantMessages.length).toBeGreaterThan(0); + }); + + it('should parse file with file diff in tool results', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'with_file_diff.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session).not.toBeNull(); + expect(session.messages.length).toBeGreaterThan(0); + + // Find tool_use messages with file diffs + const toolUseMessages = session.messages.filter(m => m.type === 'tool_use'); + expect(toolUseMessages.length).toBeGreaterThan(0); + + // Check that at least one tool_use has results + const toolWithResults = toolUseMessages.find(m => { + const toolUse = m as { type: 'tool_use'; results?: Array }; + return toolUse.results && toolUse.results.length > 0; + }); + expect(toolWithResults).toBeDefined(); + }); + + it('should parse user message with array content', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'with_file_diff.json'), 'utf-8'); + const session = parser.parseContent(content); + + const userMessages = session.messages.filter(m => m.type === 'user'); + expect(userMessages.length).toBeGreaterThan(0); + + // First message should be user with array content + const firstUserMsg = userMessages[0] as { type: 'user'; content: Array<{ type: string; text?: string }> }; + expect(firstUserMsg.content[0].type).toBe('text'); + if (firstUserMsg.content[0].type === 'text') { + expect(firstUserMsg.content[0].text).toContain('hello world'); + } + }); + + it('should handle content as string vs array', () => { + // Test with string content (from mixed_conversation.json) + const stringContent = fs.readFileSync(path.join(fixturesDir, 'user_message.json'), 'utf-8'); + const stringSession = parser.parseContent(stringContent); + + // Test with array content (from with_file_diff.json) + const arrayContent = fs.readFileSync(path.join(fixturesDir, 'with_file_diff.json'), 'utf-8'); + const arraySession = parser.parseContent(arrayContent); + + expect(stringSession.messages).toHaveLength(1); + expect(arraySession.messages.length).toBeGreaterThan(0); + + // Both should have valid user messages + const stringUser = stringSession.messages.find(m => m.type === 'user') as { type: 'user'; content: Array }; + const arrayUser = arraySession.messages.find(m => m.type === 'user') as { type: 'user'; content: Array }; + + expect(stringUser).toBeDefined(); + expect(arrayUser).toBeDefined(); + expect(stringUser.content.length).toBeGreaterThan(0); + expect(arrayUser.content.length).toBeGreaterThan(0); + }); + }); + + describe('title extraction', () => { + it('should extract title from first user message', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'user_message.json'), 'utf-8'); + const session = parser.parseContent(content); + expect(session.title).toBe('Hello, this is a simple user message'); + }); + + it('should truncate long titles', () => { + const longMessage = { + sessionId: 'test-long-title', + projectHash: 'abc123', + startTime: '2026-02-08T17:00:00.000Z', + lastUpdated: '2026-02-08T17:00:00.000Z', + messages: [ + { + id: 'msg-001', + timestamp: '2026-02-08T17:00:00.000Z', + type: 'user', + content: 'a'.repeat(150) + } + ] + }; + + const session = parser.parseContent(JSON.stringify(longMessage)); + expect(session.title.length).toBeLessThanOrEqual(103); // 100 + '...' + expect(session.title.endsWith('...')).toBe(true); + }); + + it('should use default title when no user message exists', () => { + const noUserMessage = { + sessionId: 'test-no-user', + projectHash: 'abc123', + startTime: '2026-02-08T17:00:00.000Z', + lastUpdated: '2026-02-08T17:00:00.000Z', + messages: [ + { + id: 'msg-001', + timestamp: '2026-02-08T17:00:00.000Z', + type: 'gemini', + content: 'Just an assistant message' + } + ] + }; + + const session = parser.parseContent(JSON.stringify(noUserMessage)); + expect(session.title).toBe('Gemini Session'); + }); + }); + + describe('metadata extraction', () => { + it('should extract model information from messages', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'mixed_conversation.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session.metadata).toBeDefined(); + expect(session.metadata?.models).toBeDefined(); + expect(session.metadata?.models.length).toBeGreaterThan(0); + + // Should have gemini-3-flash-preview model + const modelEntry = session.metadata?.models.find(m => m[0] === 'gemini-3-flash-preview'); + expect(modelEntry).toBeDefined(); + expect(modelEntry![1]).toBeGreaterThan(0); // Count should be > 0 + }); + + it('should track message count', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'mixed_conversation.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session.metadata?.messageCount).toBeGreaterThan(0); + }); + + it('should extract timestamps', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'user_message.json'), 'utf-8'); + const session = parser.parseContent(content); + + expect(session.metadata?.created).toBe('2026-02-08T17:00:00.000Z'); + expect(session.metadata?.modified).toBe('2026-02-08T17:01:00.000Z'); + }); + }); + + describe('thought parsing', () => { + it('should parse thoughts into assistant_thinking messages', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'mixed_conversation.json'), 'utf-8'); + const session = parser.parseContent(content); + + const thinkingMessages = session.messages.filter(m => m.type === 'assistant_thinking'); + expect(thinkingMessages.length).toBeGreaterThan(0); + + const firstThought = thinkingMessages[0] as { type: 'assistant_thinking'; thinking: string }; + expect(firstThought.thinking).toContain('Determining Project Identity'); + }); + + it('should handle empty thoughts array', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'assistant_simple.json'), 'utf-8'); + const session = parser.parseContent(content); + + // Should not create thinking messages when thoughts array is empty + const thinkingMessages = session.messages.filter(m => m.type === 'assistant_thinking'); + expect(thinkingMessages).toHaveLength(0); + }); + }); + + describe('tool call parsing', () => { + it('should parse tool calls with input arguments', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'mixed_conversation.json'), 'utf-8'); + const session = parser.parseContent(content); + + const toolUseMessages = session.messages.filter(m => m.type === 'tool_use'); + expect(toolUseMessages.length).toBeGreaterThan(0); + + // Find the read_file tool + const readFileTool = toolUseMessages.find(m => { + const tool = m as { type: 'tool_use'; toolName: string }; + return tool.toolName === 'ReadFile'; + }) as { type: 'tool_use'; toolName: string; input: Record }; + + expect(readFileTool).toBeDefined(); + expect(readFileTool.input).toHaveProperty('file_path'); + expect(readFileTool.input.file_path).toBe('package.json'); + }); + + it('should connect tool results to tool use', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'mixed_conversation.json'), 'utf-8'); + const session = parser.parseContent(content); + + const toolUseMessages = session.messages.filter(m => m.type === 'tool_use'); + + // Find a tool with results + const toolWithResults = toolUseMessages.find(m => { + const tool = m as { type: 'tool_use'; results?: Array }; + return tool.results && tool.results.length > 0; + }) as { type: 'tool_use'; results: Array<{ output: string }> }; + + expect(toolWithResults).toBeDefined(); + expect(toolWithResults.results.length).toBeGreaterThan(0); + expect(toolWithResults.results[0].output).toContain('my-mega-memory'); + }); + + it('should parse file diff from resultDisplay', () => { + const content = fs.readFileSync(path.join(fixturesDir, 'with_file_diff.json'), 'utf-8'); + const session = parser.parseContent(content); + + const toolUseMessages = session.messages.filter(m => m.type === 'tool_use'); + + // Find write_file tool with diff + const writeFileTool = toolUseMessages.find(m => { + const tool = m as { type: 'tool_use'; toolName: string; results: Array }; + return tool.toolName === 'WriteFile' && tool.results.some(r => + r.output && r.output.includes('Index:') + ); + }); + + expect(writeFileTool).toBeDefined(); + }); + }); +}); diff --git a/src/adapters/gemini/adapter.ts b/src/adapters/gemini/adapter.ts new file mode 100644 index 0000000..970c23a --- /dev/null +++ b/src/adapters/gemini/adapter.ts @@ -0,0 +1,41 @@ +import { SessionProvider } from '../../types'; +import { SessionAdapter, SessionWithProject } from '../sessionAdapter'; +import { GeminiSessionFinder, GeminiSessionParser } from './index'; + +export class GeminiAdapter implements SessionAdapter { + readonly provider = SessionProvider.GEMINI; + readonly label = 'Gemini'; + + private finder: GeminiSessionFinder; + private parser: GeminiSessionParser; + + constructor() { + this.finder = new GeminiSessionFinder(); + this.parser = new GeminiSessionParser(); + } + + async getSessions(): Promise { + const sessions: SessionWithProject[] = []; + const sessionInfos = this.finder.listSessions(); + + for (const info of sessionInfos) { + try { + const session = this.parser.parseFile(info.filePath); + if (session) { + sessions.push({ + session, + provider: SessionProvider.GEMINI, + projectPath: info.projectPath, + projectName: info.projectName, + created: info.created, + updated: info.updated + }); + } + } catch (e) { + console.error(`Error parsing Gemini session ${info.sessionId}:`, e); + } + } + + return sessions; + } +} diff --git a/src/adapters/gemini/index.ts b/src/adapters/gemini/index.ts new file mode 100644 index 0000000..fd88cab --- /dev/null +++ b/src/adapters/gemini/index.ts @@ -0,0 +1,431 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { SessionDetail, SessionMetadata, ParsedMessage, MessageContent } from '../../types'; + +// Gemini data structures +interface GeminiThought { + subject: string; + description: string; + timestamp: string; +} + +interface GeminiToolCall { + id: string; + name: string; + args: Record; + result?: any[]; + status: 'success' | 'error'; + timestamp: string; + resultDisplay?: { + fileDiff?: string; + fileName?: string; + filePath?: string; + originalContent?: string; + newContent?: string; + isNewFile?: boolean; + }; + displayName?: string; + description?: string; +} + +interface GeminiTokens { + input: number; + output: number; + cached: number; + thoughts: number; + tool: number; + total: number; +} + +interface GeminiMessage { + id: string; + timestamp: string; + type: 'user' | 'gemini' | 'error' | 'info'; + content: string | Array<{ text: string }>; + thoughts?: GeminiThought[]; + tokens?: GeminiTokens; + model?: string; + toolCalls?: GeminiToolCall[]; +} + +interface GeminiSessionData { + sessionId: string; + projectHash: string; + startTime: string; + lastUpdated: string; + messages: GeminiMessage[]; +} + +export interface GeminiSessionInfo { + sessionId: string; + projectHash: string; + projectName: string; + projectPath: string; + filePath: string; + created: string; + updated: string; + messageCount: number; +} + +/** + * Gemini CLI session finder + * Locates session files in ~/.gemini/tmp/{project}/chats/ + * Each project directory contains a .project_root file with the actual path + */ +export class GeminiSessionFinder { + private readonly baseDir: string; + + constructor() { + this.baseDir = path.join(os.homedir(), '.gemini', 'tmp'); + } + + /** + * Read project path from .project_root file + * Returns null if file doesn't exist or can't be read + */ + private readProjectRoot(projectDir: string): string | null { + const projectRootFile = path.join(this.baseDir, projectDir, '.project_root'); + + if (!fs.existsSync(projectRootFile)) { + return null; + } + + try { + const content = fs.readFileSync(projectRootFile, 'utf-8').trim(); + return content || null; + } catch (e) { + return null; + } + } + + /** + * Extract project name from a path + */ + private extractProjectName(projectPath: string): string { + return path.basename(projectPath); + } + + /** + * List all session files across all projects + * Discovers projects by iterating directories and reading .project_root files + */ + listSessions(): GeminiSessionInfo[] { + const sessions: GeminiSessionInfo[] = []; + + if (!fs.existsSync(this.baseDir)) { + return sessions; + } + + // Iterate over all directories in the tmp folder + const entries = fs.readdirSync(this.baseDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const projectDir = entry.name; + const projectDirPath = path.join(this.baseDir, projectDir); + + // Read .project_root to get the actual project path + const projectRoot = this.readProjectRoot(projectDir); + if (!projectRoot) { + // Skip directories without .project_root + continue; + } + + const projectName = this.extractProjectName(projectRoot); + const projectPath = projectRoot; + + // Look for chats subdirectory + const chatsDir = path.join(projectDirPath, 'chats'); + if (!fs.existsSync(chatsDir)) continue; + + // Find all session files + const files = fs.readdirSync(chatsDir) + .filter(f => f.endsWith('.json') && f.startsWith('session-')); + + for (const file of files) { + const filePath = path.join(chatsDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as GeminiSessionData; + + sessions.push({ + sessionId: session.sessionId, + projectHash: session.projectHash, + projectName, + projectPath, + filePath, + created: session.startTime, + updated: session.lastUpdated, + messageCount: session.messages.length + }); + } catch (e) { + // Skip invalid session files + } + } + } + + return sessions.sort((a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime()); + } +} + +/** + * Gemini CLI session parser + * Parses session JSON files into unified SessionDetail format + */ +export class GeminiSessionParser { + + constructor() { + } + + /** + * Parse a session file by path + */ + parseFile(filePath: string): SessionDetail | null { + if (!fs.existsSync(filePath)) return null; + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return this.parseContent(content); + } catch (e) { + console.error(`Error parsing Gemini session file ${filePath}:`, e); + return null; + } + } + + /** + * Parse session content + */ + parseContent(content: string): SessionDetail { + const sessionData = JSON.parse(content) as GeminiSessionData; + const messages: ParsedMessage[] = []; + const modelCounts = new Map(); + + for (const msg of sessionData.messages) { + // Track model usage + if (msg.model) { + modelCounts.set(msg.model, (modelCounts.get(msg.model) || 0) + 1); + } + + // Parse the message + const parsedMessages = this.parseMessage(msg); + messages.push(...parsedMessages); + } + + // Sort models by usage count + const sortedModels = Array.from(modelCounts.entries()) + .sort((a, b) => b[1] - a[1]); + + const metadata: SessionMetadata = { + models: sortedModels, + messageCount: messages.length, + created: sessionData.startTime, + modified: sessionData.lastUpdated + }; + + const title = this.extractTitle(sessionData.messages); + + return { + sessionId: sessionData.sessionId, + title, + messages, + metadata + }; + } + + /** + * Parse a single Gemini message into ParsedMessage(s) + */ + private parseMessage(msg: GeminiMessage): ParsedMessage[] { + const messages: ParsedMessage[] = []; + const timestamp = msg.timestamp; + + switch (msg.type) { + case 'user': + messages.push(this.parseUserMessage(msg, timestamp)); + break; + + case 'gemini': + // First add thoughts as thinking messages + if (msg.thoughts && msg.thoughts.length > 0) { + for (const thought of msg.thoughts) { + messages.push({ + type: 'assistant_thinking', + timestamp: thought.timestamp || timestamp, + thinking: `[${thought.subject}]\n${thought.description}` + }); + } + } + + // Then add tool calls + if (msg.toolCalls && msg.toolCalls.length > 0) { + for (const toolCall of msg.toolCalls) { + messages.push(...this.parseToolCall(toolCall, timestamp)); + } + } + + // Finally add the text content + if (msg.content) { + const content = this.extractContent(msg.content); + if (content.trim()) { + messages.push({ + type: 'assistant_text', + timestamp, + content: [{ type: 'markdown', markdown: content }] + }); + } + } + break; + + case 'error': + messages.push({ + type: 'info', + timestamp, + title: 'error', + content: { + type: 'text', + text: this.extractContent(msg.content) + }, + style: 'error' + }); + break; + + case 'info': + messages.push({ + type: 'info', + timestamp, + title: 'info', + content: { + type: 'text', + text: this.extractContent(msg.content) + }, + style: 'default' + }); + break; + } + + return messages; + } + + /** + * Parse a user message + */ + private parseUserMessage(msg: GeminiMessage, timestamp: string): ParsedMessage { + const content = this.extractContent(msg.content); + + return { + type: 'user', + timestamp, + content: [{ type: 'text', text: content }] + }; + } + + /** + * Parse a tool call into tool_use and tool_result messages + */ + private parseToolCall(toolCall: GeminiToolCall, timestamp: string): ParsedMessage[] { + const messages: ParsedMessage[] = []; + const toolCallId = toolCall.id; + const toolName = toolCall.displayName || toolCall.name || 'tool'; + + // Create tool_use message + const inputMap: Record = {}; + if (toolCall.args) { + for (const [key, value] of Object.entries(toolCall.args)) { + inputMap[key] = typeof value === 'string' ? value : JSON.stringify(value); + } + } + + // Parse tool results + const results: { output: string; isError: boolean; toolCallId?: string }[] = []; + + if (toolCall.result && toolCall.result.length > 0) { + for (const result of toolCall.result) { + if (result.functionResponse?.response?.output !== undefined) { + const output = result.functionResponse.response.output; + results.push({ + output: typeof output === 'string' ? output : JSON.stringify(output, null, 2), + isError: toolCall.status === 'error', + toolCallId + }); + } + } + } + + // Check for file diff in resultDisplay + if (toolCall.resultDisplay?.fileDiff) { + results.push({ + output: toolCall.resultDisplay.fileDiff, + isError: false, + toolCallId + }); + } + + messages.push({ + type: 'tool_use', + timestamp, + toolName, + toolCallId, + input: inputMap, + results + }); + + // Add tool_result for each result + for (const result of results) { + const outputContent: MessageContent[] = []; + + // Check if it's a diff + if (result.output.includes('---') && result.output.includes('+++')) { + outputContent.push({ type: 'diff', oldText: '', newText: result.output, filePath: toolCall.resultDisplay?.filePath }); + } else { + outputContent.push({ type: 'code', code: result.output }); + } + + messages.push({ + type: 'tool_result', + timestamp, + toolName, + toolCallId, + output: outputContent, + isError: result.isError + }); + } + + return messages; + } + + /** + * Extract text content from message content field + */ + private extractContent(content: string | Array<{ text: string }>): string { + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + return content.map(c => c.text || '').join('\n'); + } + + return ''; + } + + /** + * Extract title from first user message + */ + private extractTitle(messages: GeminiMessage[]): string { + // Find first user message + const userMsg = messages.find(m => m.type === 'user'); + if (!userMsg) return 'Gemini Session'; + + const content = this.extractContent(userMsg.content); + if (!content) return 'Gemini Session'; + + // Truncate if too long + if (content.length > 100) { + return content.slice(0, 100) + '...'; + } + + return content; + } +} diff --git a/src/adapters/importer.ts b/src/adapters/importer.ts index cad61b1..cafcbbb 100644 --- a/src/adapters/importer.ts +++ b/src/adapters/importer.ts @@ -19,6 +19,7 @@ import { CodexAdapter } from './codex/adapter'; import { AmpAdapter } from './amp/adapter'; import { JunieAdapter } from './junie/adapter'; import { KiloSessionAdapter } from './kilocode/adapter'; +import { GeminiAdapter } from './gemini/adapter'; function createDefaultAdapters(): SessionAdapter[] { return [ @@ -28,6 +29,7 @@ function createDefaultAdapters(): SessionAdapter[] { new AmpAdapter(), new JunieAdapter(), new KiloSessionAdapter(), + new GeminiAdapter(), ]; } diff --git a/src/public/assets/icons/providers/gemini.svg b/src/public/assets/icons/providers/gemini.svg new file mode 100644 index 0000000..cf2915f --- /dev/null +++ b/src/public/assets/icons/providers/gemini.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/types.ts b/src/types.ts index 8a24a80..f66ddf5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,8 @@ export enum SessionProvider { CODEX = 'codex', AMP = 'amp', JUNIE = 'junie', - KILO_CODE = 'kilocode' + KILO_CODE = 'kilocode', + GEMINI = 'gemini' } /**