mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 14:34:55 -07:00
feat(core): enhance session learnings summary hook with configurable output and descriptive names
This commit is contained in:
@@ -797,6 +797,7 @@ export async function loadCliConfig(
|
|||||||
noBrowser: !!process.env['NO_BROWSER'],
|
noBrowser: !!process.env['NO_BROWSER'],
|
||||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||||
sessionLearnings: settings.general?.sessionLearnings?.enabled,
|
sessionLearnings: settings.general?.sessionLearnings?.enabled,
|
||||||
|
sessionLearningsOutputPath: settings.general?.sessionLearnings?.outputPath,
|
||||||
ideMode,
|
ideMode,
|
||||||
disableLoopDetection: settings.model?.disableLoopDetection,
|
disableLoopDetection: settings.model?.disableLoopDetection,
|
||||||
compressionThreshold: settings.model?.compressionThreshold,
|
compressionThreshold: settings.model?.compressionThreshold,
|
||||||
|
|||||||
@@ -341,6 +341,16 @@ const SETTINGS_SCHEMA = {
|
|||||||
'Automatically generate a session-learnings.md file when the session ends.',
|
'Automatically generate a session-learnings.md file when the session ends.',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
},
|
},
|
||||||
|
outputPath: {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Output Path',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: undefined as string | undefined,
|
||||||
|
description:
|
||||||
|
'Directory where session-learnings files should be saved. Defaults to project root.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ export interface ConfigParameters {
|
|||||||
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
||||||
disableLLMCorrection?: boolean;
|
disableLLMCorrection?: boolean;
|
||||||
sessionLearnings?: boolean;
|
sessionLearnings?: boolean;
|
||||||
|
sessionLearningsOutputPath?: string;
|
||||||
plan?: boolean;
|
plan?: boolean;
|
||||||
onModelChange?: (model: string) => void;
|
onModelChange?: (model: string) => void;
|
||||||
mcpEnabled?: boolean;
|
mcpEnabled?: boolean;
|
||||||
@@ -664,6 +665,7 @@ export class Config {
|
|||||||
private readonly experimentalJitContext: boolean;
|
private readonly experimentalJitContext: boolean;
|
||||||
private readonly disableLLMCorrection: boolean;
|
private readonly disableLLMCorrection: boolean;
|
||||||
private readonly sessionLearnings: boolean;
|
private readonly sessionLearnings: boolean;
|
||||||
|
private readonly sessionLearningsOutputPath: string | undefined;
|
||||||
private readonly planEnabled: boolean;
|
private readonly planEnabled: boolean;
|
||||||
private contextManager?: ContextManager;
|
private contextManager?: ContextManager;
|
||||||
private terminalBackground: string | undefined = undefined;
|
private terminalBackground: string | undefined = undefined;
|
||||||
@@ -753,6 +755,7 @@ export class Config {
|
|||||||
this.agents = params.agents ?? {};
|
this.agents = params.agents ?? {};
|
||||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||||
this.sessionLearnings = params.sessionLearnings ?? false;
|
this.sessionLearnings = params.sessionLearnings ?? false;
|
||||||
|
this.sessionLearningsOutputPath = params.sessionLearningsOutputPath;
|
||||||
this.planEnabled = params.plan ?? false;
|
this.planEnabled = params.plan ?? false;
|
||||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||||
this.skillsSupport = params.skillsSupport ?? true;
|
this.skillsSupport = params.skillsSupport ?? true;
|
||||||
@@ -1956,6 +1959,10 @@ export class Config {
|
|||||||
return this.sessionLearnings;
|
return this.sessionLearnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSessionLearningsOutputPath(): string | undefined {
|
||||||
|
return this.sessionLearningsOutputPath;
|
||||||
|
}
|
||||||
|
|
||||||
isPlanEnabled(): boolean {
|
isPlanEnabled(): boolean {
|
||||||
return this.planEnabled;
|
return this.planEnabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,30 @@ describe('SessionLearningsService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
mockGenerateContent = vi.fn().mockResolvedValue({
|
mockGenerateContent = vi.fn().mockImplementation((_params, promptId) => {
|
||||||
candidates: [
|
if (promptId === 'session-learnings-generation') {
|
||||||
{
|
return Promise.resolve({
|
||||||
content: {
|
candidates: [
|
||||||
parts: [{ text: '# Session Learnings\nSummary text here.' }],
|
{
|
||||||
},
|
content: {
|
||||||
},
|
parts: [{ text: '# Session Learnings\nSummary text here.' }],
|
||||||
],
|
},
|
||||||
} as unknown as GenerateContentResponse);
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse);
|
||||||
|
} else if (promptId === 'session-summary-generation') {
|
||||||
|
return Promise.resolve({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'Mock Session Title' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected promptId: ${promptId}`));
|
||||||
|
});
|
||||||
|
|
||||||
mockContentGenerator = {
|
mockContentGenerator = {
|
||||||
generateContent: mockGenerateContent,
|
generateContent: mockGenerateContent,
|
||||||
@@ -52,6 +67,7 @@ describe('SessionLearningsService', () => {
|
|||||||
|
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(true),
|
isSessionLearningsEnabled: vi.fn().mockReturnValue(true),
|
||||||
|
getSessionLearningsOutputPath: vi.fn().mockReturnValue(undefined),
|
||||||
getGeminiClient: () => mockGeminiClient,
|
getGeminiClient: () => mockGeminiClient,
|
||||||
getContentGenerator: () => mockContentGenerator,
|
getContentGenerator: () => mockContentGenerator,
|
||||||
getWorkingDir: () => '/mock/cwd',
|
getWorkingDir: () => '/mock/cwd',
|
||||||
@@ -78,25 +94,67 @@ describe('SessionLearningsService', () => {
|
|||||||
service = new SessionLearningsService(mockConfig as Config);
|
service = new SessionLearningsService(mockConfig as Config);
|
||||||
|
|
||||||
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
|
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate and save learnings when enabled and enough messages exist', async () => {
|
it('should generate and save learnings with descriptive filename', async () => {
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
await service.generateAndSaveLearnings();
|
await service.generateAndSaveLearnings();
|
||||||
|
|
||||||
expect(mockGenerateContent).toHaveBeenCalled();
|
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||||
path.join('/mock/cwd', 'session-learnings.md'),
|
path.join('/mock/cwd', `learnings-Mock-Session-Title-${dateStr}.md`),
|
||||||
|
'# Session Learnings\nSummary text here.',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom output path if configured', async () => {
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
(mockConfig as any).getSessionLearningsOutputPath.mockReturnValue(
|
||||||
|
'custom/path',
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.generateAndSaveLearnings();
|
||||||
|
|
||||||
|
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||||
|
path.join('/mock/cwd', 'custom/path'),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||||
|
path.join(
|
||||||
|
'/mock/cwd',
|
||||||
|
'custom/path',
|
||||||
|
`learnings-Mock-Session-Title-${dateStr}.md`,
|
||||||
|
),
|
||||||
|
'# Session Learnings\nSummary text here.',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use absolute output path if configured', async () => {
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
(mockConfig as any).getSessionLearningsOutputPath.mockReturnValue(
|
||||||
|
'/absolute/path',
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.generateAndSaveLearnings();
|
||||||
|
|
||||||
|
expect(fs.mkdir).toHaveBeenCalledWith('/absolute/path', {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||||
|
path.join('/absolute/path', `learnings-Mock-Session-Title-${dateStr}.md`),
|
||||||
'# Session Learnings\nSummary text here.',
|
'# Session Learnings\nSummary text here.',
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not generate learnings if disabled', async () => {
|
it('should not generate learnings if disabled', async () => {
|
||||||
|
|
||||||
(mockConfig as any).isSessionLearningsEnabled.mockReturnValue(false);
|
(mockConfig as any).isSessionLearningsEnabled.mockReturnValue(false);
|
||||||
|
|
||||||
await service.generateAndSaveLearnings();
|
await service.generateAndSaveLearnings();
|
||||||
@@ -106,7 +164,6 @@ describe('SessionLearningsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not generate learnings if not enough messages', async () => {
|
it('should not generate learnings if not enough messages', async () => {
|
||||||
|
|
||||||
mockRecordingService.getConversation.mockReturnValue({
|
mockRecordingService.getConversation.mockReturnValue({
|
||||||
messages: [{ type: 'user', content: [{ text: 'Single message' }] }],
|
messages: [{ type: 'user', content: [{ text: 'Single message' }] }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ import { BaseLlmClient } from '../core/baseLlmClient.js';
|
|||||||
import { debugLogger } from '../utils/debugLogger.js';
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||||
import { getResponseText } from '../utils/partUtils.js';
|
import { getResponseText } from '../utils/partUtils.js';
|
||||||
|
import { SessionSummaryService } from './sessionSummaryService.js';
|
||||||
|
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
||||||
import type { Content } from '@google/genai';
|
import type { Content } from '@google/genai';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const MIN_MESSAGES = 2;
|
const MIN_MESSAGES = 2;
|
||||||
const MAX_MESSAGES_FOR_CONTEXT = 30;
|
const TIMEOUT_MS = 60000; // Increased timeout for potentially larger context
|
||||||
const MAX_MESSAGE_LENGTH = 1000;
|
|
||||||
const TIMEOUT_MS = 30000;
|
|
||||||
|
|
||||||
const LEARNINGS_FILENAME = 'session-learnings.md';
|
|
||||||
|
|
||||||
const LEARNINGS_PROMPT = `It's time to pause on this development. Looking back at what you have done so far:
|
const LEARNINGS_PROMPT = `It's time to pause on this development. Looking back at what you have done so far:
|
||||||
Prepare a summary of the problem you were trying to solve, the analysis synthesized, and information you would need to implement this request if you were to start again
|
Prepare a summary of the problem you were trying to solve, the analysis synthesized, and information you would need to implement this request if you were to start again
|
||||||
@@ -60,19 +58,12 @@ export class SessionLearningsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare transcript
|
// Prepare transcript (no max messages, no max length)
|
||||||
const relevantMessages = conversation.messages.slice(
|
const transcript = conversation.messages
|
||||||
-MAX_MESSAGES_FOR_CONTEXT,
|
|
||||||
);
|
|
||||||
const transcript = relevantMessages
|
|
||||||
.map((msg) => {
|
.map((msg) => {
|
||||||
const role = msg.type === 'user' ? 'User' : 'Assistant';
|
const role = msg.type === 'user' ? 'User' : 'Assistant';
|
||||||
const content = partListUnionToString(msg.content);
|
const content = partListUnionToString(msg.content);
|
||||||
const truncated =
|
return `[${role}]: ${content}`;
|
||||||
content.length > MAX_MESSAGE_LENGTH
|
|
||||||
? content.slice(0, MAX_MESSAGE_LENGTH) + '...'
|
|
||||||
: content;
|
|
||||||
return `[${role}]: ${truncated}`;
|
|
||||||
})
|
})
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
@@ -105,19 +96,47 @@ export class SessionLearningsService {
|
|||||||
promptId: 'session-learnings-generation',
|
promptId: 'session-learnings-generation',
|
||||||
});
|
});
|
||||||
|
|
||||||
const summary = getResponseText(response);
|
const summaryText = getResponseText(response);
|
||||||
if (!summary) {
|
if (!summaryText) {
|
||||||
debugLogger.warn(
|
debugLogger.warn(
|
||||||
'[SessionLearnings] Failed to generate summary (empty response)',
|
'[SessionLearnings] Failed to generate summary (empty response)',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(
|
// Generate descriptive filename
|
||||||
this.config.getWorkingDir(),
|
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||||
LEARNINGS_FILENAME,
|
const sessionTitle = await summaryService.generateSummary({
|
||||||
);
|
messages: conversation.messages,
|
||||||
await fs.writeFile(filePath, summary, 'utf-8');
|
});
|
||||||
|
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
const sanitizedTitle = sessionTitle
|
||||||
|
? sanitizeFilenamePart(sessionTitle.trim().replace(/\s+/g, '-'))
|
||||||
|
: 'untitled';
|
||||||
|
|
||||||
|
const fileName = `learnings-${sanitizedTitle}-${dateStr}.md`;
|
||||||
|
|
||||||
|
// Determine output directory
|
||||||
|
const configOutputPath = this.config.getSessionLearningsOutputPath();
|
||||||
|
let outputDir = this.config.getWorkingDir();
|
||||||
|
|
||||||
|
if (configOutputPath) {
|
||||||
|
if (path.isAbsolute(configOutputPath)) {
|
||||||
|
outputDir = configOutputPath;
|
||||||
|
} else {
|
||||||
|
outputDir = path.join(
|
||||||
|
this.config.getWorkingDir(),
|
||||||
|
configOutputPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const filePath = path.join(outputDir, fileName);
|
||||||
|
await fs.writeFile(filePath, summaryText, 'utf-8');
|
||||||
debugLogger.log(
|
debugLogger.log(
|
||||||
`[SessionLearnings] Saved session learnings to ${filePath}`,
|
`[SessionLearnings] Saved session learnings to ${filePath}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user