mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 22:14:52 -07:00
Add global session history and naming workflow
This commit is contained in:
@@ -156,6 +156,18 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'session-name-default': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
generateContentConfig: {
|
||||
maxOutputTokens: 128,
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'web-search': {
|
||||
extends: 'gemini-3-flash-base',
|
||||
modelConfig: {
|
||||
|
||||
@@ -109,6 +109,7 @@ export * from './utils/constants.js';
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
export * from './services/gitService.js';
|
||||
export * from './services/chatRecordingService.js';
|
||||
export * from './services/sessionNamingService.js';
|
||||
export * from './services/fileSystemService.js';
|
||||
export * from './services/sessionSummaryUtils.js';
|
||||
export * from './services/contextManager.js';
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
import { type Config } from '../config/config.js';
|
||||
import { type Status } from '../core/coreToolScheduler.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
|
||||
import { getProjectHash } from '../utils/paths.js';
|
||||
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
||||
@@ -20,6 +22,13 @@ import type {
|
||||
} from '@google/genai';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { ToolResultDisplay } from '../tools/tools.js';
|
||||
import {
|
||||
ensureSessionNameBase,
|
||||
generateSessionNameBase,
|
||||
getDefaultSessionNameBase,
|
||||
getSessionNameSuffix,
|
||||
normalizeSessionNameBase,
|
||||
} from './sessionNamingService.js';
|
||||
|
||||
export const SESSION_FILE_PREFIX = 'session-';
|
||||
|
||||
@@ -99,6 +108,10 @@ export interface ConversationRecord {
|
||||
startTime: string;
|
||||
lastUpdated: string;
|
||||
messages: MessageRecord[];
|
||||
/** User-defined or AI-generated session name base (suffix stored separately). */
|
||||
sessionNameBase?: string;
|
||||
/** Immutable session name suffix derived from sessionId. */
|
||||
sessionNameSuffix?: string;
|
||||
summary?: string;
|
||||
/** Workspace directories added during the session via /dir add */
|
||||
directories?: string[];
|
||||
@@ -131,11 +144,16 @@ export class ChatRecordingService {
|
||||
private queuedThoughts: Array<ThoughtSummary & { timestamp: string }> = [];
|
||||
private queuedTokens: TokensSummary | null = null;
|
||||
private config: Config;
|
||||
private sessionNameSuffix: string;
|
||||
private fallbackSessionNameBase: string;
|
||||
private sessionNameGenerationStarted = false;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.sessionId = config.getSessionId();
|
||||
this.projectHash = getProjectHash(config.getProjectRoot());
|
||||
this.sessionNameSuffix = getSessionNameSuffix(this.sessionId);
|
||||
this.fallbackSessionNameBase = getDefaultSessionNameBase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,15 +166,22 @@ export class ChatRecordingService {
|
||||
// Resume from existing session
|
||||
this.conversationFile = resumedSessionData.filePath;
|
||||
this.sessionId = resumedSessionData.conversation.sessionId;
|
||||
this.sessionNameSuffix = getSessionNameSuffix(this.sessionId);
|
||||
this.fallbackSessionNameBase = getDefaultSessionNameBase(
|
||||
new Date(resumedSessionData.conversation.startTime || Date.now()),
|
||||
);
|
||||
this.sessionNameGenerationStarted = true;
|
||||
|
||||
// Update the session ID in the existing file
|
||||
this.updateConversation((conversation) => {
|
||||
conversation.sessionId = this.sessionId;
|
||||
this.ensureSessionNaming(conversation);
|
||||
});
|
||||
|
||||
// Clear any cached data to force fresh reads
|
||||
this.cachedLastConvData = null;
|
||||
} else {
|
||||
this.sessionNameGenerationStarted = false;
|
||||
// Create new session
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
@@ -179,6 +204,8 @@ export class ChatRecordingService {
|
||||
projectHash: this.projectHash,
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
sessionNameBase: this.fallbackSessionNameBase,
|
||||
sessionNameSuffix: this.sessionNameSuffix,
|
||||
messages: [],
|
||||
});
|
||||
}
|
||||
@@ -234,8 +261,13 @@ export class ChatRecordingService {
|
||||
}): void {
|
||||
if (!this.conversationFile) return;
|
||||
|
||||
let firstUserMessage: string | null = null;
|
||||
let expectedSessionNameBase: string | null = null;
|
||||
|
||||
try {
|
||||
this.updateConversation((conversation) => {
|
||||
this.ensureSessionNaming(conversation);
|
||||
|
||||
const msg = this.newMessage(
|
||||
message.type,
|
||||
message.content,
|
||||
@@ -254,8 +286,24 @@ export class ChatRecordingService {
|
||||
} else {
|
||||
// Or else just add it.
|
||||
conversation.messages.push(msg);
|
||||
|
||||
const meaningfulUserMessage = this.getMeaningfulUserMessage(
|
||||
message.content,
|
||||
);
|
||||
if (meaningfulUserMessage && !this.sessionNameGenerationStarted) {
|
||||
this.sessionNameGenerationStarted = true;
|
||||
firstUserMessage = meaningfulUserMessage;
|
||||
expectedSessionNameBase = conversation.sessionNameBase ?? null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (firstUserMessage && expectedSessionNameBase) {
|
||||
void this.generateAndSaveSessionName(
|
||||
firstUserMessage,
|
||||
expectedSessionNameBase,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error saving message to chat history.', error);
|
||||
throw error;
|
||||
@@ -433,6 +481,8 @@ export class ChatRecordingService {
|
||||
projectHash: this.projectHash,
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
sessionNameBase: this.fallbackSessionNameBase,
|
||||
sessionNameSuffix: this.sessionNameSuffix,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
@@ -486,6 +536,68 @@ export class ChatRecordingService {
|
||||
this.writeConversation(conversation);
|
||||
}
|
||||
|
||||
private ensureSessionNaming(conversation: ConversationRecord): void {
|
||||
conversation.sessionNameSuffix =
|
||||
conversation.sessionNameSuffix || this.sessionNameSuffix;
|
||||
conversation.sessionNameBase = ensureSessionNameBase(
|
||||
conversation.sessionNameBase || this.fallbackSessionNameBase,
|
||||
);
|
||||
}
|
||||
|
||||
private getMeaningfulUserMessage(content: PartListUnion): string | null {
|
||||
const text = partListUnionToString(content).trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
if (text.startsWith('/') || text.startsWith('?')) {
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private async generateAndSaveSessionName(
|
||||
firstUserRequest: string,
|
||||
expectedSessionNameBase: string,
|
||||
): Promise<void> {
|
||||
if (!this.conversationFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, this.config);
|
||||
const generatedBase = await generateSessionNameBase({
|
||||
baseLlmClient,
|
||||
firstUserRequest,
|
||||
});
|
||||
if (!generatedBase) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateConversation((conversation) => {
|
||||
this.ensureSessionNaming(conversation);
|
||||
|
||||
const currentBase = normalizeSessionNameBase(
|
||||
conversation.sessionNameBase || '',
|
||||
);
|
||||
const expectedBase = normalizeSessionNameBase(expectedSessionNameBase);
|
||||
|
||||
// Avoid overwriting user-initiated renames while async generation was pending.
|
||||
if (currentBase.length === 0 || currentBase === expectedBase) {
|
||||
conversation.sessionNameBase = generatedBase;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`[SessionName] Failed to generate session name: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a summary for the current session.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import {
|
||||
buildSessionName,
|
||||
ensureSessionNameBase,
|
||||
generateSessionNameBase,
|
||||
getDefaultSessionNameBase,
|
||||
getSessionNameSuffix,
|
||||
normalizeSessionNameBase,
|
||||
normalizeSessionNameSuffix,
|
||||
} from './sessionNamingService.js';
|
||||
|
||||
describe('sessionNamingService', () => {
|
||||
it('normalizes session name base as kebab-case', () => {
|
||||
expect(normalizeSessionNameBase('Fix Login Bug!!')).toBe('fix-login-bug');
|
||||
});
|
||||
|
||||
it('returns default fallback base when empty', () => {
|
||||
const base = ensureSessionNameBase('');
|
||||
expect(base.startsWith('session-')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates deterministic 5-char suffix', () => {
|
||||
expect(getSessionNameSuffix('123e4567-e89b-12d3-a456-426614174000')).toBe(
|
||||
'123e4',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes suffix with padding', () => {
|
||||
expect(normalizeSessionNameSuffix('ab')).toBe('ab000');
|
||||
});
|
||||
|
||||
it('builds full name with immutable suffix', () => {
|
||||
expect(buildSessionName('Fix auth flow', 'abc12')).toBe(
|
||||
'fix-auth-flow-abc12',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats default session base from timestamp', () => {
|
||||
const base = getDefaultSessionNameBase(new Date('2026-02-13T12:34:56Z'));
|
||||
expect(base).toBe('session-20260213-123456');
|
||||
});
|
||||
|
||||
it('generates normalized name from model output', async () => {
|
||||
const baseLlmClient = {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'Fix API auth bug' }] } }],
|
||||
}),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const generated = await generateSessionNameBase({
|
||||
baseLlmClient,
|
||||
firstUserRequest: 'Please help fix our API authentication bug',
|
||||
});
|
||||
|
||||
expect(generated).toBe('fix-api-auth-bug');
|
||||
});
|
||||
|
||||
it('returns null when model output is empty', async () => {
|
||||
const baseLlmClient = {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: ' ' }] } }],
|
||||
}),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const generated = await generateSessionNameBase({
|
||||
baseLlmClient,
|
||||
firstUserRequest: 'name this',
|
||||
});
|
||||
|
||||
expect(generated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
|
||||
export const SESSION_NAME_SUFFIX_LENGTH = 5;
|
||||
const SESSION_NAME_BASE_MAX_LENGTH = 60;
|
||||
const SESSION_NAME_TIMEOUT_MS = 4000;
|
||||
|
||||
const SESSION_NAME_PROMPT = `Generate a short session name based on the user's FIRST request.
|
||||
|
||||
Rules:
|
||||
- 3 to 8 words max
|
||||
- Focus on user's goal
|
||||
- No punctuation
|
||||
- No quotes
|
||||
|
||||
First user request:
|
||||
{request}
|
||||
|
||||
Session name:`;
|
||||
|
||||
function leftPad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function getDefaultSessionNameBase(date: Date = new Date()): string {
|
||||
return `session-${date.getUTCFullYear()}${leftPad(date.getUTCMonth() + 1)}${leftPad(date.getUTCDate())}-${leftPad(date.getUTCHours())}${leftPad(date.getUTCMinutes())}${leftPad(date.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function normalizeSessionNameBase(input: string): string {
|
||||
const normalized = input
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\x00-\x7F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, SESSION_NAME_BASE_MAX_LENGTH)
|
||||
.replace(/-+$/g, '');
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeSessionNameSuffix(input: string): string {
|
||||
const alphanumeric = input.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
if (alphanumeric.length === 0) {
|
||||
return '00000';
|
||||
}
|
||||
|
||||
if (alphanumeric.length >= SESSION_NAME_SUFFIX_LENGTH) {
|
||||
return alphanumeric.slice(0, SESSION_NAME_SUFFIX_LENGTH);
|
||||
}
|
||||
|
||||
return alphanumeric.padEnd(SESSION_NAME_SUFFIX_LENGTH, '0');
|
||||
}
|
||||
|
||||
export function getSessionNameSuffix(sessionId: string): string {
|
||||
return normalizeSessionNameSuffix(sessionId);
|
||||
}
|
||||
|
||||
export function ensureSessionNameBase(base: string | undefined): string {
|
||||
const normalized = normalizeSessionNameBase(base ?? '');
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
return getDefaultSessionNameBase();
|
||||
}
|
||||
|
||||
export function buildSessionName(base: string, suffix: string): string {
|
||||
return `${ensureSessionNameBase(base)}-${normalizeSessionNameSuffix(suffix)}`;
|
||||
}
|
||||
|
||||
export interface GenerateSessionNameOptions {
|
||||
baseLlmClient: BaseLlmClient;
|
||||
firstUserRequest: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function generateSessionNameBase({
|
||||
baseLlmClient,
|
||||
firstUserRequest,
|
||||
timeoutMs = SESSION_NAME_TIMEOUT_MS,
|
||||
}: GenerateSessionNameOptions): Promise<string | null> {
|
||||
const trimmedRequest = firstUserRequest.trim();
|
||||
if (!trimmedRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prompt = SESSION_NAME_PROMPT.replace('{request}', trimmedRequest);
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
];
|
||||
|
||||
const response = await baseLlmClient.generateContent({
|
||||
modelConfigKey: { model: 'session-name-default' },
|
||||
contents,
|
||||
abortSignal: abortController.signal,
|
||||
promptId: 'session-name-generation',
|
||||
});
|
||||
|
||||
const generated = getResponseText(response);
|
||||
if (!generated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeSessionNameBase(generated);
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user