Add global session history and naming workflow

This commit is contained in:
Dmitry Lyalin
2026-02-12 23:29:34 -05:00
parent d82f66973f
commit 0806784b90
27 changed files with 1434 additions and 895 deletions
@@ -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: {
+1
View File
@@ -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);
}
}