fix: remove gemini support since google discontinued it

This commit is contained in:
Haileyesus
2026-07-06 17:01:29 +03:00
parent 6daae87443
commit 4e423f5fa2
100 changed files with 247 additions and 3799 deletions

View File

@@ -100,9 +100,9 @@ test('assignProviderSessionId merges a watcher-created duplicate into the app ro
test('legacy provider-keyed rows stay resolvable through both lookups', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo');
sessionsDb.createSession('legacy-1', 'opencode', '/workspace/demo');
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini');
assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'opencode');
assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1');
});
});

View File

@@ -13,7 +13,6 @@ const PROVIDER_LABELS = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
system: 'System'
};

View File

@@ -36,7 +36,6 @@ Current provider ids in this repo are:
- `claude`
- `codex`
- `cursor`
- `gemini`
- `opencode`
Those ids are mirrored in backend unions and frontend provider constants. If
@@ -56,8 +55,7 @@ server/modules/providers/list/<provider>/
<provider>-session-synchronizer.provider.ts
```
The existing provider folders are `claude`, `codex`, `cursor`, `gemini`, and
`opencode`.
The existing provider folders are `claude`, `codex`, `cursor`, and `opencode`.
## What Each Facet Does
@@ -123,7 +121,6 @@ Current MCP formats in this repo are:
| Claude | `.mcp.json` in user / local / project locations | `user`, `local`, `project` | `stdio`, `http`, `sse` |
| Codex | `.codex/config.toml` | `user`, `project` | `stdio`, `http` |
| Cursor | `.cursor/mcp.json` | `user`, `project` | `stdio`, `http` |
| Gemini | `.gemini/settings.json` | `user`, `project` | `stdio`, `http` |
| OpenCode | `~/.config/opencode/opencode.json` or `<workspace>/opencode.json` (`.jsonc` is read when present) | `user`, `project` | `stdio`, `http` |
5. Implement skills.
@@ -144,7 +141,6 @@ Current skill discovery roots are:
| Claude | `~/.claude/skills` | `<workspace>/.claude/skills` | `/` | Also discovers Claude plugin skills from enabled plugin installs. Command skills live under `commands/`; markdown skills live under `skills/` and are scanned recursively. |
| Codex | `~/.agents/skills`, `~/.codex/skills/.system`, `/etc/codex/skills` | `<workspace>/.agents/skills`, `path.dirname(workspacePath)/.agents/skills`, topmost git root `.agents/skills` | `$` | Overlapping roots are deduplicated before scanning. |
| Cursor | `~/.cursor/skills` | `<workspace>/.cursor/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
| Gemini | `~/.gemini/skills`, `~/.agents/skills` | `<workspace>/.gemini/skills`, `<workspace>/.agents/skills` | `/` | Uses slash-style commands. |
| OpenCode | `~/.config/opencode/skills`, `~/.claude/skills`, `~/.agents/skills` | Cwd-to-topmost-git-root `.opencode/skills`, `.claude/skills`, and `.agents/skills` | `/` | Reuses OpenCode, Claude, and Agents skill locations. Overlapping roots are deduplicated before scanning. |
Command forms currently used by the providers are:
@@ -153,7 +149,6 @@ Command forms currently used by the providers are:
- Claude plugin skills: `/plugin-name:skill-name`
- Codex skills: `$skill-name`
- Cursor skills: `/skill-name`
- Gemini skills: `/skill-name`
- OpenCode skills: `/skill-name`
6. Implement sessions.
@@ -191,7 +186,6 @@ Current session sync roots are:
| Claude | `~/.claude/projects/**/*.jsonl` | Uses `~/.claude/history.jsonl` for name lookup and the trailing `ai-title`, `last-prompt`, or `custom-title` entries for title recovery. |
| Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. |
| Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. |
| Gemini | `~/.gemini/tmp/**/*.jsonl` | Current full scans only index temp JSONL chat artifacts. Single-file sync also accepts legacy `.json` files. |
| OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. |
8. Register the provider.

View File

@@ -445,11 +445,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
label: "gpt-5.2-xhigh-fast",
description: "GPT-5.2 Extra High Fast",
},
{
value: "gemini-3.1-pro",
label: "gemini-3.1-pro",
description: "Gemini 3.1 Pro",
},
{
value: "gpt-5.4-mini-none",
label: "gpt-5.4-mini-none",
@@ -530,16 +525,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
label: "gpt-5.1-high",
description: "GPT-5.1 High",
},
{
value: "gemini-3-flash",
label: "gemini-3-flash",
description: "Gemini 3 Flash",
},
{
value: "gemini-3.5-flash",
label: "gemini-3.5-flash",
description: "Gemini 3.5 Flash",
},
{
value: "gpt-5.1-codex-mini-low",
label: "gpt-5.1-codex-mini-low",

View File

@@ -1,307 +0,0 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type GeminiCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
type GeminiAuthType =
| 'oauth-personal'
| 'gemini-api-key'
| 'vertex-ai'
| 'compute-default-credentials'
| 'gateway'
| 'cloud-shell'
| null;
export class GeminiProviderAuth implements IProviderAuth {
/**
* Gemini CLI can override its home root via GEMINI_CLI_HOME.
* Use the same resolution so status checks match runtime behavior.
*/
private getGeminiCliHome(): string {
return process.env.GEMINI_CLI_HOME?.trim() || os.homedir();
}
/**
* Checks whether the Gemini CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Gemini CLI installation and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'gemini',
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI is not installed',
};
}
const credentials = await this.checkCredentials();
return {
installed,
provider: 'gemini',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Parses dotenv-style key/value pairs.
*/
private parseEnvFile(content: string): Record<string, string> {
const parsed: Record<string, string> = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const normalizedLine = line.startsWith('export ')
? line.slice('export '.length).trim()
: line;
const separatorIndex = normalizedLine.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = normalizedLine.slice(0, separatorIndex).trim();
if (!key) {
continue;
}
let value = normalizedLine.slice(separatorIndex + 1).trim();
const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''));
if (quoted) {
value = value.slice(1, -1);
} else {
value = value.replace(/\s+#.*$/, '').trim();
}
parsed[key] = value;
}
return parsed;
}
/**
* Loads user-level auth env in Gemini's "first file found" order.
*/
private async loadUserLevelAuthEnv(): Promise<Record<string, string>> {
const geminiCliHome = this.getGeminiCliHome();
const envCandidates = [
path.join(geminiCliHome, '.gemini', '.env'),
path.join(geminiCliHome, '.env'),
];
for (const envPath of envCandidates) {
try {
const content = await readFile(envPath, 'utf8');
return this.parseEnvFile(content);
} catch {
// Continue to the next fallback.
}
}
return {};
}
/**
* Reads Gemini's selected auth type from settings.json when available.
*/
private async readSelectedAuthType(): Promise<GeminiAuthType> {
try {
const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json');
const content = await readFile(settingsPath, 'utf8');
const settings = readObjectRecord(JSON.parse(content));
const security = readObjectRecord(settings?.security);
const auth = readObjectRecord(security?.auth);
const selectedType = readOptionalString(auth?.selectedType);
if (!selectedType) {
return null;
}
return selectedType as GeminiAuthType;
} catch {
return null;
}
}
/**
* Checks Gemini credentials from API key env vars or local OAuth credential files.
*/
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
if (process.env.GEMINI_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const userEnv = await this.loadUserLevelAuthEnv();
if (readOptionalString(userEnv.GEMINI_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const selectedType = await this.readSelectedAuthType();
if (selectedType === 'vertex-ai') {
const hasGoogleApiKey = Boolean(
process.env.GOOGLE_API_KEY?.trim()
|| readOptionalString(userEnv.GOOGLE_API_KEY)
);
const hasProject = Boolean(
process.env.GOOGLE_CLOUD_PROJECT?.trim()
|| process.env.GOOGLE_CLOUD_PROJECT_ID?.trim()
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT)
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID)
);
const hasLocation = Boolean(
process.env.GOOGLE_CLOUD_LOCATION?.trim()
|| readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION)
);
const hasServiceAccount = Boolean(
process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim()
|| readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS)
);
if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) {
return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' };
}
return {
authenticated: false,
email: null,
method: 'vertex_ai',
error: 'Gemini is set to Vertex AI, but required env vars are missing',
};
}
try {
const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json');
const content = await readFile(credsPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const accessToken = readOptionalString(creds.access_token);
if (!accessToken) {
return {
authenticated: false,
email: null,
method: null,
error: 'No valid tokens found in oauth_creds',
};
}
const refreshToken = readOptionalString(creds.refresh_token);
const tokenInfo = await this.getTokenInfoEmail(accessToken);
if (tokenInfo.valid) {
return {
authenticated: true,
email: tokenInfo.email || 'OAuth Session',
method: 'credentials_file',
};
}
if (!refreshToken) {
return {
authenticated: false,
email: null,
method: 'credentials_file',
error: 'Access token invalid and no refresh token found',
};
}
return {
authenticated: true,
email: await this.getActiveAccountEmail() || 'OAuth Session',
method: 'credentials_file',
};
} catch {
if (selectedType === 'gemini-api-key') {
return {
authenticated: false,
email: null,
method: 'api_key',
error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable',
};
}
if (selectedType === 'oauth-personal') {
return {
authenticated: false,
email: null,
method: 'credentials_file',
error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found',
};
}
// If no explicit auth type was selected, surface the generic "not configured" error.
return {
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI not configured',
};
}
}
/**
* Validates a Gemini OAuth access token and returns an email when Google reports one.
*/
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
if (!tokenRes.ok) {
return { valid: false, email: null };
}
const tokenInfo = readObjectRecord(await tokenRes.json());
return {
valid: true,
email: readOptionalString(tokenInfo?.email) ?? null,
};
} catch {
return { valid: false, email: null };
}
}
/**
* Reads Gemini's active local Google account as an offline fallback for display.
*/
private async getActiveAccountEmail(): Promise<string | null> {
try {
const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json');
const accContent = await readFile(accPath, 'utf8');
const accounts = readObjectRecord(JSON.parse(accContent));
return readOptionalString(accounts?.active) ?? null;
} catch {
return null;
}
}
}

View File

@@ -1,110 +0,0 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class GeminiMcpProvider extends McpProvider {
constructor() {
super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.gemini', 'settings.json')
: path.join(workspacePath, '.gemini', 'settings.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.gemini', 'settings.json')
: path.join(workspacePath, '.gemini', 'settings.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'gemini',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
return {
provider: 'gemini',
name,
scope,
transport,
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -1,39 +0,0 @@
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
],
DEFAULT: 'gemini-3-flash-preview',
};
export class GeminiProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
return GEMINI_FALLBACK_MODELS;
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
}
async changeActiveModel(
input: ProviderChangeActiveModelInput,
): Promise<ProviderSessionActiveModelChange> {
return writeProviderSessionActiveModelChange('gemini', input);
}
}

View File

@@ -1,405 +0,0 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import {
findFilesRecursivelyCreatedAfter,
normalizeProjectPath,
normalizeSessionName,
readFileTimestamps,
} from '@/shared/utils.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
import type { AnyRecord } from '@/shared/types.js';
type ParsedSession = {
sessionId: string;
projectPath: string;
sessionName?: string;
};
type GeminiJsonlMetadata = {
sessionId: string;
projectPath?: string;
projectHash?: string;
firstUserMessage?: string;
};
/**
* Session indexer for Gemini transcript artifacts.
*/
export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'gemini' as const;
private readonly geminiHome = path.join(os.homedir(), '.gemini');
/**
* Scans Gemini legacy JSON and new JSONL artifacts and upserts sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const projectHashLookup = this.buildProjectHashLookup();
// const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
// path.join(this.geminiHome, 'sessions'),
// '.json',
// since ?? null
// );
// Gemini creates overlapping artifacts across `sessions/` and `tmp/`.
// We currently index only `tmp/*/chats/*.jsonl` because those files are the
// live transcript source and avoid duplicate session rows from mirrored files.
// const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
// path.join(this.geminiHome, 'tmp'),
// '.json',
// since ?? null
// );
// const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
// path.join(this.geminiHome, 'sessions'),
// '.jsonl',
// since ?? null
// );
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
path.join(this.geminiHome, 'tmp'),
'.jsonl',
since ?? null
);
// Current strategy: index only temp chat JSONL artifacts.
const files = [
// ...legacySessionFiles,
// Intentionally disabled to avoid duplicate indexing from mirrored
// `sessions/*.json` and `sessions/*.jsonl` artifacts.
// ...legacyTempFiles,
// ...jsonlSessionFiles,
...jsonlTempFiles,
];
let processed = 0;
for (const filePath of files) {
if (this.shouldSkipTempArtifact(filePath)) {
continue;
}
const parsed = filePath.endsWith('.jsonl')
? await this.processJsonlSessionFile(filePath, projectHashLookup)
: await this.processLegacySessionFile(filePath);
if (!parsed) {
continue;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
processed += 1;
}
return processed;
}
/**
* Parses and upserts one Gemini legacy JSON or JSONL artifact.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (!filePath.endsWith('.json') && !filePath.endsWith('.jsonl')) {
return null;
}
if (this.shouldSkipTempArtifact(filePath)) {
return null;
}
const parsed = filePath.endsWith('.jsonl')
? await this.processJsonlSessionFile(filePath, this.buildProjectHashLookup())
: await this.processLegacySessionFile(filePath);
if (!parsed) {
return null;
}
const timestamps = await readFileTimestamps(filePath);
return sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.projectPath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath
);
}
/**
* Extracts session metadata from one Gemini legacy JSON artifact.
*/
private async processLegacySessionFile(filePath: string): Promise<ParsedSession | null> {
try {
const content = await readFile(filePath, 'utf8');
const data = JSON.parse(content) as AnyRecord;
const sessionId =
typeof data.sessionId === 'string'
? data.sessionId
: typeof data.id === 'string'
? data.id
: undefined;
if (!sessionId) {
return null;
}
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
const projectPath = typeof data.projectPath === 'string' && data.projectPath.trim().length > 0
? data.projectPath
: workspaceProjectPath;
if (!projectPath) {
return null;
}
const messages = Array.isArray(data.messages) ? data.messages : [];
const firstMessage = messages[0] as AnyRecord | undefined;
let rawName: string | undefined;
if (Array.isArray(firstMessage?.content) && typeof firstMessage.content[0]?.text === 'string') {
rawName = firstMessage.content[0].text;
} else if (typeof firstMessage?.content === 'string') {
rawName = firstMessage.content;
}
return {
sessionId,
projectPath,
sessionName: normalizeSessionName(rawName, 'New Gemini Chat'),
};
} catch {
return null;
}
}
/**
* Extracts session metadata from one Gemini JSONL artifact.
*/
private async processJsonlSessionFile(
filePath: string,
projectHashLookup: Map<string, string>
): Promise<ParsedSession | null> {
const metadata = await this.extractJsonlMetadata(filePath);
if (!metadata) {
return null;
}
let projectPath = typeof metadata.projectPath === 'string' ? metadata.projectPath.trim() : '';
if (!projectPath) {
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
if (workspaceProjectPath) {
projectPath = workspaceProjectPath;
}
}
if (!projectPath && typeof metadata.projectHash === 'string') {
projectPath = projectHashLookup.get(metadata.projectHash.trim().toLowerCase()) ?? '';
}
if (!projectPath) {
return null;
}
// Once we resolve a project hash/path pair, keep it in-memory for this sync run.
if (typeof metadata.projectHash === 'string' && metadata.projectHash.trim()) {
projectHashLookup.set(metadata.projectHash.trim().toLowerCase(), projectPath);
}
return {
sessionId: metadata.sessionId,
projectPath,
sessionName: normalizeSessionName(metadata.firstUserMessage, 'New Gemini Chat'),
};
}
/**
* Reads first useful metadata from Gemini JSONL files.
*/
private async extractJsonlMetadata(filePath: string): Promise<GeminiJsonlMetadata | null> {
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split('\n');
let sessionId: string | undefined;
let projectPath: string | undefined;
let projectHash: string | undefined;
let firstUserMessage: string | undefined;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let parsed: AnyRecord;
try {
parsed = JSON.parse(trimmed) as AnyRecord;
} catch {
continue;
}
if (!sessionId && typeof parsed.sessionId === 'string') {
sessionId = parsed.sessionId;
}
if (!projectPath && typeof parsed.projectPath === 'string') {
projectPath = parsed.projectPath;
}
if (!projectHash && typeof parsed.projectHash === 'string') {
projectHash = parsed.projectHash;
}
if (!firstUserMessage && parsed.type === 'user') {
firstUserMessage = this.extractGeminiTextContent(parsed.content);
}
if (sessionId && (projectPath || projectHash) && firstUserMessage) {
break;
}
}
if (!sessionId) {
return null;
}
return {
sessionId,
projectPath,
projectHash,
firstUserMessage,
};
} catch {
return null;
}
}
/**
* Tries to resolve project root from Gemini tmp chat workspaces.
*/
private async resolveProjectPathFromChatWorkspace(filePath: string): Promise<string> {
if (!filePath.includes(`${path.sep}chats${path.sep}`)) {
return '';
}
const chatsDir = path.dirname(filePath);
const workspaceDir = path.dirname(chatsDir);
const projectRootPath = path.join(workspaceDir, '.project_root');
try {
const rootContent = await readFile(projectRootPath, 'utf8');
return rootContent.trim();
} catch {
return '';
}
}
/**
* Builds a hash->path lookup for Gemini JSONL metadata that stores projectHash.
*/
private buildProjectHashLookup(): Map<string, string> {
const lookup = new Map<string, string>();
const knownPaths = new Set<string>();
for (const project of projectsDb.getProjectPaths()) {
if (typeof project.project_path === 'string' && project.project_path.trim()) {
knownPaths.add(project.project_path.trim());
}
}
for (const session of sessionsDb.getAllSessions()) {
if (session.provider === this.provider && typeof session.project_path === 'string' && session.project_path.trim()) {
knownPaths.add(session.project_path.trim());
}
}
for (const knownPath of knownPaths) {
this.addProjectHashCandidates(lookup, knownPath);
}
return lookup;
}
/**
* Adds likely Gemini hash variants for one project path.
*/
private addProjectHashCandidates(lookup: Map<string, string>, projectPath: string): void {
const trimmed = projectPath.trim();
if (!trimmed) {
return;
}
const normalized = normalizeProjectPath(trimmed);
const resolved = path.resolve(trimmed);
const resolvedNormalized = normalizeProjectPath(resolved);
const candidates = new Set<string>([
trimmed,
normalized,
resolved,
resolvedNormalized,
]);
if (process.platform === 'win32') {
for (const candidate of [...candidates]) {
candidates.add(candidate.toLowerCase());
}
}
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const hash = this.sha256(candidate);
if (!lookup.has(hash)) {
lookup.set(hash, trimmed);
}
}
}
/**
* Returns first user text from Gemini content payload shapes.
*/
private extractGeminiTextContent(content: unknown): string | undefined {
if (typeof content === 'string' && content.trim().length > 0) {
return content;
}
if (!Array.isArray(content)) {
return undefined;
}
for (const part of content) {
if (typeof part === 'string' && part.trim().length > 0) {
return part;
}
if (part && typeof part === 'object' && typeof (part as AnyRecord).text === 'string') {
const text = (part as AnyRecord).text;
if (text.trim().length > 0) {
return text;
}
}
}
return undefined;
}
/**
* Keeps tmp scanning scoped to chat artifacts only.
*/
private shouldSkipTempArtifact(filePath: string): boolean {
return (
filePath.startsWith(path.join(this.geminiHome, 'tmp'))
&& !filePath.includes(`${path.sep}chats${path.sep}`)
);
}
private sha256(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
}
}

View File

@@ -1,555 +0,0 @@
import fsSync from 'node:fs';
import fs from 'node:fs/promises';
import readline from 'node:readline';
import { sessionsDb } from '@/modules/database/index.js';
import { parseImagesInputTag } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
const PROVIDER = 'gemini';
type GeminiHistoryResult = {
messages: AnyRecord[];
tokenUsage?: unknown;
};
function mapGeminiRole(value: unknown): 'user' | 'assistant' | null {
if (value === 'user') {
return 'user';
}
if (value === 'gemini' || value === 'assistant') {
return 'assistant';
}
return null;
}
function extractGeminiTextContent(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.map((part) => {
if (typeof part === 'string') {
return part;
}
if (!part || typeof part !== 'object') {
return '';
}
const record = part as AnyRecord;
if (typeof record.text === 'string') {
return record.text;
}
return '';
})
.filter(Boolean)
.join('\n');
}
function extractGeminiThoughts(thoughts: unknown): string {
if (!Array.isArray(thoughts)) {
return '';
}
return thoughts
.map((item) => {
if (!item || typeof item !== 'object') {
return '';
}
const record = item as AnyRecord;
const subject = typeof record.subject === 'string' ? record.subject.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
if (subject && description) {
return `${subject}: ${description}`;
}
return description || subject;
})
.filter(Boolean)
.join('\n');
}
function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
if (!tokens || typeof tokens !== 'object') {
return undefined;
}
const record = tokens as AnyRecord;
const input = Number(record.input || 0);
const output = Number(record.output || 0);
const total = Number(record.total || input + output || 0);
return {
used: total,
inputTokens: input,
outputTokens: output,
breakdown: {
input,
output,
},
};
}
async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
try {
const data = await fs.readFile(sessionFilePath, 'utf8');
const session = JSON.parse(data) as AnyRecord;
const sourceMessages = Array.isArray(session.messages) ? session.messages : [];
const messages: AnyRecord[] = [];
for (const msg of sourceMessages) {
const role = mapGeminiRole(msg.type ?? msg.role);
if (!role) {
continue;
}
messages.push({
type: 'message',
uuid: typeof msg.id === 'string' ? msg.id : undefined,
message: { role, content: msg.content },
timestamp: msg.timestamp || null,
});
}
return { messages };
} catch {
return { messages: [] };
}
}
// Exported for tests: parses one Gemini CLI JSONL transcript from disk.
export async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
const messages: AnyRecord[] = [];
let tokenUsage: AnyRecord | undefined;
try {
const fileStream = fsSync.createReadStream(sessionFilePath);
const lineReader = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
for await (const line of lineReader) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let entry: AnyRecord;
try {
entry = JSON.parse(trimmed) as AnyRecord;
} catch {
continue;
}
// Metadata/update lines (e.g. {$set:{lastUpdated:...}}) do not represent chat messages.
if (entry.$set) {
continue;
}
const role = mapGeminiRole(entry.type);
if (role) {
const rawTextContent = extractGeminiTextContent(entry.content);
// User prompts sent with attachments carry an <images_input> path
// list; strip it for display and surface the paths as images.
const { text: textContent, attachments } = role === 'user'
? parseImagesInputTag(rawTextContent)
: { text: rawTextContent, attachments: [] };
if (textContent.trim() || attachments.length > 0) {
messages.push({
type: 'message',
uuid: typeof entry.id === 'string' ? entry.id : undefined,
message: { role, content: textContent },
images: attachments.length > 0 ? attachments : undefined,
timestamp: entry.timestamp || null,
});
}
const thinkingContent = extractGeminiThoughts(entry.thoughts);
if (thinkingContent.trim()) {
messages.push({
type: 'thinking',
uuid: typeof entry.id === 'string' ? `${entry.id}_thinking` : undefined,
message: { role: 'assistant', content: thinkingContent },
timestamp: entry.timestamp || null,
isReasoning: true,
});
}
if (role === 'assistant') {
const usage = buildGeminiTokenUsage(entry.tokens);
if (usage) {
tokenUsage = usage;
}
}
continue;
}
if (entry.type === 'tool_use') {
messages.push({
type: 'tool_use',
uuid: typeof entry.id === 'string' ? entry.id : undefined,
timestamp: entry.timestamp || null,
toolName: entry.tool_name || entry.name || 'Tool',
toolInput: entry.parameters ?? entry.input ?? entry.arguments ?? '',
toolCallId: entry.tool_id || entry.toolCallId || entry.id,
});
continue;
}
if (entry.type === 'tool_result') {
messages.push({
type: 'tool_result',
uuid: typeof entry.id === 'string' ? entry.id : undefined,
timestamp: entry.timestamp || null,
toolCallId: entry.tool_id || entry.toolCallId || entry.id || '',
output: entry.output ?? entry.result ?? '',
isError: Boolean(entry.error) || entry.status === 'error',
});
}
}
} catch {
return { messages: [] };
}
messages.sort(
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
);
return { messages, tokenUsage };
}
async function getGeminiCliSessionMessages(sessionId: string): Promise<GeminiHistoryResult> {
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
if (!sessionFilePath) {
return { messages: [] };
}
if (sessionFilePath.endsWith('.jsonl')) {
return getGeminiJsonlSessionMessages(sessionFilePath);
}
return getGeminiLegacySessionMessages(sessionFilePath);
}
export class GeminiSessionsProvider implements IProviderSessions {
/**
* Normalizes live Gemini stream-json events into the shared message shape.
*
* Gemini history uses a different session file shape, so fetchHistory handles
* that separately after loading raw persisted messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const messages: NormalizedMessage[] = [];
if (content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_delta',
content,
}));
}
if (raw.delta !== true) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
}));
}
return messages;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool_name,
toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const messages = [createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
})];
if (raw.stats?.total_tokens) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'status',
text: 'Complete',
tokens: raw.stats.total_tokens,
canInterrupt: false,
}));
}
return messages;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* Loads Gemini history from Gemini CLI session files on disk.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let result: GeminiHistoryResult;
try {
result = await getGeminiCliSessionMessages(sessionId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = result.messages;
const normalized: NormalizedMessage[] = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'thinking' || raw.isReasoning) {
const thinkingContent = typeof raw.message?.content === 'string'
? raw.message.content
: typeof raw.content === 'string'
? raw.content
: '';
if (thinkingContent.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: thinkingContent,
}));
}
continue;
}
if (raw.type === 'tool_use' || raw.toolName) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Tool',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
continue;
}
if (raw.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: Boolean(raw.isError),
}));
continue;
}
const role = raw.message?.role || raw.role;
const content = raw.message?.content ?? raw.content ?? '';
const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined;
if (!role || (!content && !rawImages)) {
continue;
}
const normalizedRole = role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx] as AnyRecord | string;
if (typeof part === 'string' && part.trim()) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part,
}));
continue;
}
if (!part || typeof part !== 'object') {
continue;
}
if ((part.type === 'text' || !part.type) && typeof part.text === 'string' && part.text.trim()) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && (content.trim() || rawImages)) {
// Legacy (non-JSONL) session files reach this branch with the raw
// prompt text, so strip any <images_input> block here as well.
const { text: cleanContent, attachments } = normalizedRole === 'user'
? parseImagesInputTag(content)
: { text: content, attachments: [] };
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: cleanContent,
images: rawImages ?? (attachments.length > 0 ? attachments : undefined),
}));
} else {
const textContent = extractGeminiTextContent(content);
if (textContent.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: textContent,
}));
}
}
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
// Tail pagination via the shared contract: offset 0 returns the most
// recent page, matching every other provider.
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
let total = 0;
for (const msg of normalized) {
if (msg.kind !== 'tool_result') {
total += 1;
}
}
return {
messages: page,
total,
hasMore,
offset: start,
limit: pageLimit,
tokenUsage: result.tokenUsage,
};
}
}

View File

@@ -1,44 +0,0 @@
import os from 'node:os';
import path from 'node:path';
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
import type { ProviderSkillSource } from '@/shared/types.js';
export class GeminiSkillsProvider extends SkillsProvider {
constructor() {
super('gemini');
}
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
return [
{
scope: 'user',
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
commandPrefix: '/',
},
{
scope: 'user',
rootDir: path.join(os.homedir(), '.agents', 'skills'),
commandPrefix: '/',
},
{
scope: 'project',
rootDir: path.join(workspacePath, '.gemini', 'skills'),
commandPrefix: '/',
},
{
scope: 'project',
rootDir: path.join(workspacePath, '.agents', 'skills'),
commandPrefix: '/',
},
];
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
commandPrefix: '/',
};
}
}

View File

@@ -1,27 +0,0 @@
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js';
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js';
import type {
IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer,
IProviderSkills,
IProviderSessions,
} from '@/shared/interfaces.js';
export class GeminiProvider extends AbstractProvider {
readonly models: IProviderModels = new GeminiProviderModels();
readonly mcp = new GeminiMcpProvider();
readonly auth: IProviderAuth = new GeminiProviderAuth();
readonly skills: IProviderSkills = new GeminiSkillsProvider();
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer();
constructor() {
super('gemini');
}
}

View File

@@ -19,7 +19,6 @@ const OPENCODE_ENV_CREDENTIAL_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GOOGLE_GENERATIVE_AI_API_KEY',
'GEMINI_API_KEY',
'GROQ_API_KEY',
'OPENROUTER_API_KEY',
];

View File

@@ -49,16 +49,6 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
label: 'GPT-5.4 Mini',
description: 'openai - openai/gpt-5.4-mini',
},
{
value: 'google/gemini-2.5-pro',
label: 'Gemini 2.5 Pro',
description: 'google - google/gemini-2.5-pro',
},
{
value: 'google/gemini-2.5-flash',
label: 'Gemini 2.5 Flash',
description: 'google - google/gemini-2.5-flash',
},
],
DEFAULT: 'anthropic/claude-sonnet-4-5',
};
@@ -239,6 +229,10 @@ const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: s
};
};
const isSupportedOpenCodeModelId = (id: string): boolean => (
readOpenCodeModelParts(id).upstreamProvider.toLowerCase() !== 'google'
);
const readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => {
const id = readOptionalString(model.id);
if (!id) {
@@ -296,7 +290,7 @@ const readOpenCodeEffortValues = (
const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => {
const value = readOpenCodeVerboseModelId(model);
if (!value) {
if (!value || !isSupportedOpenCodeModelId(value)) {
return null;
}
@@ -315,11 +309,13 @@ const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOpti
};
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = ids.map((value) => ({
value,
label: labelForOpenCodeModelId(value),
description: descriptionForOpenCodeModelId(value),
}));
const options: ProviderModelOption[] = ids
.filter(isSupportedOpenCodeModelId)
.map((value) => ({
value,
label: labelForOpenCodeModelId(value),
description: descriptionForOpenCodeModelId(value),
}));
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
?? options[0]?.value

View File

@@ -1,7 +1,6 @@
import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js';
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
@@ -11,7 +10,6 @@ const providers: Record<LLMProvider, IProvider> = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
cursor: new CursorProvider(),
gemini: new GeminiProvider(),
opencode: new OpenCodeProvider(),
};

View File

@@ -285,7 +285,6 @@ const parseProvider = (value: unknown): LLMProvider => {
normalized === 'claude'
|| normalized === 'codex'
|| normalized === 'cursor'
|| normalized === 'gemini'
|| normalized === 'opencode'
) {
return normalized;

View File

@@ -62,16 +62,6 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsTokenUsage: true,
supportsEffort: true,
},
gemini: {
provider: 'gemini',
permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
defaultPermissionMode: 'default',
supportsImages: true,
supportsAbort: true,
supportsPermissionRequests: false,
supportsTokenUsage: true,
supportsEffort: false,
},
opencode: {
provider: 'opencode',
// Mapped by the runtime onto OpenCode's controls: `--agent plan` (plan),

View File

@@ -17,7 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 2;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;

View File

@@ -8,7 +8,7 @@ import { rgPath } from '@vscode/ripgrep';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
type AnyRecord = Record<string, any>;
type SearchableProvider = 'claude' | 'codex' | 'gemini';
type SearchableProvider = 'claude' | 'codex';
type SearchSnippetHighlight = {
start: number;
@@ -82,7 +82,7 @@ type ProjectBucket = {
sessions: SearchableSessionRow[];
};
const SUPPORTED_PROVIDERS = new Set<SearchableProvider>(['claude', 'codex', 'gemini']);
const SUPPORTED_PROVIDERS = new Set<SearchableProvider>(['claude', 'codex']);
const MAX_MATCHES_PER_SESSION = 2;
const RIPGREP_FILE_CHUNK_SIZE = 40;
const RIPGREP_CHUNK_CONCURRENCY = 6;
@@ -455,21 +455,6 @@ function extractCodexText(content: unknown): string {
.join(' ');
}
function extractGeminiText(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.filter((part: AnyRecord) => typeof part?.text === 'string')
.map((part: AnyRecord) => String(part.text))
.join(' ');
}
function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSessionRow[] {
const normalizedRows: SearchableSessionRow[] = [];
const projectArchiveStateByPath = new Map<string, boolean>();
@@ -1065,81 +1050,6 @@ async function parseCodexSessionMatches(
};
}
async function parseGeminiSessionMatches(
session: SearchableSessionRow,
runtime: SearchRuntime,
): Promise<SessionConversationResult | null> {
let data: string;
try {
data = await fs.readFile(session.jsonl_path, 'utf8');
} catch {
return null;
}
let parsed: AnyRecord;
try {
parsed = JSON.parse(data) as AnyRecord;
} catch {
return null;
}
const sourceMessages = Array.isArray(parsed.messages) ? parsed.messages as AnyRecord[] : [];
if (sourceMessages.length === 0) {
return null;
}
const matches: SessionConversationMatch[] = [];
let firstUserText: string | null = null;
for (const msg of sourceMessages) {
if (runtime.totalMatches >= runtime.limit || runtime.isAborted()) {
break;
}
const role = msg.type === 'user'
? 'user'
: (msg.type === 'gemini' || msg.type === 'assistant')
? 'assistant'
: null;
if (!role) {
continue;
}
const text = extractGeminiText(msg.content);
if (!text) {
continue;
}
if (role === 'user' && !firstUserText) {
firstUserText = text;
}
if (!runtime.matchesQuery(text)) {
continue;
}
const { snippet, highlights } = runtime.buildSnippet(text);
addSessionMatch(runtime, matches, {
role,
snippet,
highlights,
timestamp: msg.timestamp ? String(msg.timestamp) : null,
provider: 'gemini',
});
}
if (matches.length === 0) {
return null;
}
return {
sessionId: session.session_id,
provider: 'gemini',
sessionSummary: toSummaryText(session.custom_name, firstUserText, 'Gemini Session'),
matches,
};
}
async function parseSessionMatches(
session: SearchableSessionRow,
runtime: SearchRuntime,
@@ -1150,7 +1060,7 @@ async function parseSessionMatches(
if (session.provider === 'codex') {
return parseCodexSessionMatches(session, runtime);
}
return parseGeminiSessionMatches(session, runtime);
return null;
}
export async function searchConversations(

View File

@@ -21,7 +21,6 @@ export const sessionSynchronizerService = {
claude: 0,
codex: 0,
cursor: 0,
gemini: 0,
opencode: 0,
};
const failures: string[] = [];

View File

@@ -25,16 +25,6 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
provider: 'codex',
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
},
// {
// provider: 'gemini',
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
// },
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
// which causes duplicate synchronization events.
{
provider: 'gemini',
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
},
{
provider: 'opencode',
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
@@ -81,10 +71,6 @@ function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
return path.basename(filePath) === 'opencode.db';
}
if (provider === 'gemini') {
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
}
return filePath.endsWith('.jsonl');
}

View File

@@ -257,34 +257,15 @@ test('providerMcpService handles opencode MCP config and capability validation',
});
/**
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
* This test covers Cursor MCP JSON format and user/project scope persistence.
*/
test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => {
test('providerMcpService handles cursor MCP JSON config formats', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('gemini', {
name: 'gemini-stdio',
scope: 'user',
transport: 'stdio',
command: 'node',
args: ['server.js'],
env: { TOKEN: '$TOKEN' },
cwd: './server',
});
await providerMcpService.upsertProviderMcpServer('gemini', {
name: 'gemini-http',
scope: 'project',
transport: 'http',
url: 'https://gemini.example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('cursor', {
name: 'cursor-stdio',
scope: 'project',
@@ -303,15 +284,6 @@ test('providerMcpService handles gemini and cursor MCP JSON config formats', { c
headers: { API_KEY: 'value' },
});
const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json'));
const geminiUserServer = (geminiUserConfig.mcpServers as Record<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
assert.equal(geminiUserServer.command, 'node');
assert.equal(geminiUserServer.type, undefined);
const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
const geminiProjectServer = (geminiProjectConfig.mcpServers as Record<string, unknown>)['gemini-http'] as Record<string, unknown>;
assert.equal(geminiProjectServer.type, 'http');
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp');
@@ -341,7 +313,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
workspacePath,
});
assert.equal(globalResult.length, 5);
assert.equal(globalResult.length, 4);
assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -350,9 +322,6 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);

View File

@@ -30,6 +30,7 @@ test('OpenCode models provider formats frontend labels from provider-prefixed id
'opencode/nemotron-3-super-free',
'anthropic/claude-3-5-sonnet-20241022',
'anthropic/claude-opus-4-7-fast',
'google/model-alpha',
'openai/gpt-5.4-mini-fast',
'openai/gpt-5.5-pro',
'newprovider/alpha-v12-special-20261231',
@@ -104,6 +105,12 @@ anthropic/claude-sonnet-5
}
}
}
google/model-alpha
{
"id": "model-alpha",
"providerID": "google",
"name": "Model Alpha"
}
`);
const definition = buildOpenCodeDefinitionFromVerboseModels(models);

View File

@@ -1,13 +1,9 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
import { CodexSessionsProvider, extractCodexUserImages } from '@/modules/providers/list/codex/codex-sessions.provider.js';
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
import { getGeminiJsonlSessionMessages } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
import { appendImagesInputTag } from '@/shared/image-attachments.js';
const SESSION_ID = 'session-1';
@@ -127,58 +123,6 @@ test('codex history: normalized user entries keep their images', () => {
assert.deepEqual(messages[0].images, [{ path: '.cloudcli/assets/a.png' }]);
});
// ---------------------------------------------------------------- Gemini
test('gemini history: <images_input> tag is stripped and paths attached', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gemini-image-history-'));
const jsonlPath = path.join(tempDir, 'session.jsonl');
try {
const taggedPrompt = appendImagesInputTag('Compare these designs', [
{ path: '.cloudcli/assets/1-a.png' },
{ path: '.cloudcli/assets/2-b.png' },
]);
const lines = [
JSON.stringify({ type: 'user', id: 'm1', content: taggedPrompt, timestamp: '2026-07-03T10:00:00.000Z' }),
JSON.stringify({ type: 'gemini', id: 'm2', content: 'They differ in spacing.', timestamp: '2026-07-03T10:00:05.000Z' }),
];
await writeFile(jsonlPath, `${lines.join('\n')}\n`, 'utf8');
const { messages } = await getGeminiJsonlSessionMessages(jsonlPath);
assert.equal(messages.length, 2);
assert.equal(messages[0].message.role, 'user');
assert.equal(messages[0].message.content, 'Compare these designs');
assert.deepEqual(messages[0].images, [
{ path: '.cloudcli/assets/1-a.png' },
{ path: '.cloudcli/assets/2-b.png' },
]);
// Assistant text is left untouched.
assert.equal(messages[1].message.role, 'assistant');
assert.equal(messages[1].images, undefined);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test('gemini history: prompts without a tag are unchanged', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gemini-image-history-'));
const jsonlPath = path.join(tempDir, 'session.jsonl');
try {
await writeFile(
jsonlPath,
`${JSON.stringify({ type: 'user', id: 'm1', content: 'plain prompt', timestamp: '2026-07-03T10:00:00.000Z' })}\n`,
'utf8',
);
const { messages } = await getGeminiJsonlSessionMessages(jsonlPath);
assert.equal(messages.length, 1);
assert.equal(messages[0].message.content, 'plain prompt');
assert.equal(messages[0].images, undefined);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
// ---------------------------------------------------------------- Cursor
test('cursor history: <images_input> inside user_query is stripped and attached', () => {

View File

@@ -170,13 +170,13 @@ test('provider model cache is persisted across service instances', async () => {
cachePath,
resolveProvider: () => ({
models: {
getSupportedModels: async () => createModels('gemini-cached'),
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
getSupportedModels: async () => createModels('cursor-cached'),
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
},
}),
});
await writer.getProviderModels('gemini');
await writer.getProviderModels('cursor');
const reader = createProviderModelsService({
cachePath,
@@ -185,13 +185,13 @@ test('provider model cache is persisted across service instances', async () => {
getSupportedModels: async () => {
throw new Error('loader should not be called for persisted cache hits');
},
getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
},
}),
});
const models = await reader.getProviderModels('gemini');
assert.equal(models.models.DEFAULT, 'gemini-cached');
const models = await reader.getProviderModels('cursor');
assert.equal(models.models.DEFAULT, 'cursor-cached');
assert.equal(models.cache.source, 'disk');
} finally {
await rm(tempRoot, { recursive: true, force: true });

View File

@@ -444,34 +444,22 @@ test('providerSkillsService lists opencode project and user compatibility skills
});
/**
* This test covers Gemini and Cursor skill directory rules, including shared
* This test covers Cursor skill directory rules, including shared
* `.agents/skills` project support.
*/
test('providerSkillsService lists gemini and cursor skills from their configured directories', { concurrency: false }, async () => {
test('providerSkillsService lists cursor skills from its configured directories', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await writeSkill(
path.join(tempRoot, '.gemini', 'skills'),
'gemini-user-dir',
'gemini-user',
'Gemini user skill',
);
await writeSkill(
path.join(tempRoot, '.agents', 'skills'),
'agents-user-dir',
'agents-user',
'Agents user skill',
);
await writeSkill(
path.join(workspacePath, '.gemini', 'skills'),
'gemini-project-dir',
'gemini-project',
'Gemini project skill',
);
await writeSkill(
path.join(workspacePath, '.agents', 'skills'),
'agents-project-dir',
@@ -491,14 +479,6 @@ test('providerSkillsService lists gemini and cursor skills from their configured
'Cursor user skill',
);
const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath });
const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill]));
assert.equal(geminiByName.get('gemini-user')?.scope, 'user');
assert.equal(geminiByName.get('agents-user')?.scope, 'user');
assert.equal(geminiByName.get('gemini-project')?.scope, 'project');
assert.equal(geminiByName.get('agents-project')?.scope, 'project');
assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project');
const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
assert.equal(cursorByName.get('agents-project')?.scope, 'project');
@@ -515,7 +495,7 @@ test('providerSkillsService lists gemini and cursor skills from their configured
* This test covers managed global skill creation for providers that own a
* writable user skill directory.
*/
test('providerSkillsService adds global skills for claude, codex, gemini, and cursor', { concurrency: false }, async () => {
test('providerSkillsService adds global skills for claude, codex, and cursor', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
const restoreHomeDir = patchHomeDir(tempRoot);
@@ -618,22 +598,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
);
await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
entries: [
{
directoryName: 'gemini-global-dir',
content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
},
],
});
const createdGeminiSkill = createdGeminiSkills[0];
assert.ok(createdGeminiSkill);
assert.equal(createdGeminiSkill.command, '/gemini-global');
assert.equal(
createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
true,
);
const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
entries: [
{
@@ -656,9 +620,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);

View File

@@ -146,14 +146,6 @@ function buildShellCommand(
return 'codex';
}
if (provider === 'gemini') {
const command = initialCommand || 'gemini';
if (resumeSessionId) {
return `${command} --resume "${resumeSessionId}"`;
}
return command;
}
if (provider === 'opencode') {
if (resumeSessionId) {
return `opencode --session "${resumeSessionId}"`;
@@ -477,9 +469,7 @@ export function handleShellConnection(
? 'Cursor'
: provider === 'codex'
? 'Codex'
: provider === 'gemini'
? 'Gemini'
: provider === 'opencode'
: provider === 'opencode'
? 'OpenCode'
: 'Claude';
welcomeMsg = hasSession && resumeSessionId

View File

@@ -186,22 +186,22 @@ test('replayEvents returns only events after the requested seq', async () => {
test('attachConnection reroutes the live stream to a new socket', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo');
sessionsDb.createAppSession('app-run-5', 'opencode', '/workspace/demo');
const firstConnection = new FakeConnection();
const run = chatRunRegistry.startRun({
appSessionId: 'app-run-5',
provider: 'gemini',
provider: 'opencode',
providerSessionId: null,
connection: firstConnection,
userId: null,
});
assert.ok(run);
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' });
run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'before' });
const secondConnection = new FakeConnection();
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' });
run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'after' });
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);