feat(core): enhance session learnings summary hook with configurable output and descriptive names

This commit is contained in:
Aishanee Shah
2026-02-11 16:41:51 +00:00
parent 64e19fd60f
commit 4e60c886d2
5 changed files with 130 additions and 36 deletions
+1
View File
@@ -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,
+10
View File
@@ -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,
},
}, },
}, },
}, },
+7
View File
@@ -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}`,
); );