From 4e60c886d2b6a3f2600e2d05f2f8b2cb7d33b47c Mon Sep 17 00:00:00 2001 From: Aishanee Shah Date: Wed, 11 Feb 2026 16:41:51 +0000 Subject: [PATCH] feat(core): enhance session learnings summary hook with configurable output and descriptive names --- packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 10 +++ packages/core/src/config/config.ts | 7 ++ .../services/sessionLearningsService.test.ts | 85 ++++++++++++++++--- .../src/services/sessionLearningsService.ts | 63 +++++++++----- 5 files changed, 130 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ca5e31a2bf..7466a2c36f 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -797,6 +797,7 @@ export async function loadCliConfig( noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, sessionLearnings: settings.general?.sessionLearnings?.enabled, + sessionLearningsOutputPath: settings.general?.sessionLearnings?.outputPath, ideMode, disableLoopDetection: settings.model?.disableLoopDetection, compressionThreshold: settings.model?.compressionThreshold, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 758724d851..6578ead32e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -341,6 +341,16 @@ const SETTINGS_SCHEMA = { 'Automatically generate a session-learnings.md file when the session ends.', 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, + }, }, }, }, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 147f8c3dc7..e2635db565 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -478,6 +478,7 @@ export interface ConfigParameters { toolOutputMasking?: Partial; disableLLMCorrection?: boolean; sessionLearnings?: boolean; + sessionLearningsOutputPath?: string; plan?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; @@ -664,6 +665,7 @@ export class Config { private readonly experimentalJitContext: boolean; private readonly disableLLMCorrection: boolean; private readonly sessionLearnings: boolean; + private readonly sessionLearningsOutputPath: string | undefined; private readonly planEnabled: boolean; private contextManager?: ContextManager; private terminalBackground: string | undefined = undefined; @@ -753,6 +755,7 @@ export class Config { this.agents = params.agents ?? {}; this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.sessionLearnings = params.sessionLearnings ?? false; + this.sessionLearningsOutputPath = params.sessionLearningsOutputPath; this.planEnabled = params.plan ?? false; this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true; this.skillsSupport = params.skillsSupport ?? true; @@ -1956,6 +1959,10 @@ export class Config { return this.sessionLearnings; } + getSessionLearningsOutputPath(): string | undefined { + return this.sessionLearningsOutputPath; + } + isPlanEnabled(): boolean { return this.planEnabled; } diff --git a/packages/core/src/services/sessionLearningsService.test.ts b/packages/core/src/services/sessionLearningsService.test.ts index f1f2aacfd0..9d53202643 100644 --- a/packages/core/src/services/sessionLearningsService.test.ts +++ b/packages/core/src/services/sessionLearningsService.test.ts @@ -23,15 +23,30 @@ describe('SessionLearningsService', () => { beforeEach(() => { vi.clearAllMocks(); - mockGenerateContent = vi.fn().mockResolvedValue({ - candidates: [ - { - content: { - parts: [{ text: '# Session Learnings\nSummary text here.' }], - }, - }, - ], - } as unknown as GenerateContentResponse); + mockGenerateContent = vi.fn().mockImplementation((_params, promptId) => { + if (promptId === 'session-learnings-generation') { + return Promise.resolve({ + candidates: [ + { + content: { + parts: [{ text: '# Session Learnings\nSummary text here.' }], + }, + }, + ], + } 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 = { generateContent: mockGenerateContent, @@ -52,6 +67,7 @@ describe('SessionLearningsService', () => { mockConfig = { isSessionLearningsEnabled: vi.fn().mockReturnValue(true), + getSessionLearningsOutputPath: vi.fn().mockReturnValue(undefined), getGeminiClient: () => mockGeminiClient, getContentGenerator: () => mockContentGenerator, getWorkingDir: () => '/mock/cwd', @@ -78,25 +94,67 @@ describe('SessionLearningsService', () => { service = new SessionLearningsService(mockConfig as Config); vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined); + vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as any); }); afterEach(() => { 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(); - expect(mockGenerateContent).toHaveBeenCalled(); + expect(mockGenerateContent).toHaveBeenCalledTimes(2); 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.', 'utf-8', ); }); it('should not generate learnings if disabled', async () => { - (mockConfig as any).isSessionLearningsEnabled.mockReturnValue(false); await service.generateAndSaveLearnings(); @@ -106,7 +164,6 @@ describe('SessionLearningsService', () => { }); it('should not generate learnings if not enough messages', async () => { - mockRecordingService.getConversation.mockReturnValue({ messages: [{ type: 'user', content: [{ text: 'Single message' }] }], }); diff --git a/packages/core/src/services/sessionLearningsService.ts b/packages/core/src/services/sessionLearningsService.ts index f897f9d982..1923543d96 100644 --- a/packages/core/src/services/sessionLearningsService.ts +++ b/packages/core/src/services/sessionLearningsService.ts @@ -9,16 +9,14 @@ import { BaseLlmClient } from '../core/baseLlmClient.js'; import { debugLogger } from '../utils/debugLogger.js'; import { partListUnionToString } from '../core/geminiRequest.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 fs from 'node:fs/promises'; import path from 'node:path'; const MIN_MESSAGES = 2; -const MAX_MESSAGES_FOR_CONTEXT = 30; -const MAX_MESSAGE_LENGTH = 1000; -const TIMEOUT_MS = 30000; - -const LEARNINGS_FILENAME = 'session-learnings.md'; +const TIMEOUT_MS = 60000; // Increased timeout for potentially larger context 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 @@ -60,19 +58,12 @@ export class SessionLearningsService { return; } - // Prepare transcript - const relevantMessages = conversation.messages.slice( - -MAX_MESSAGES_FOR_CONTEXT, - ); - const transcript = relevantMessages + // Prepare transcript (no max messages, no max length) + const transcript = conversation.messages .map((msg) => { const role = msg.type === 'user' ? 'User' : 'Assistant'; const content = partListUnionToString(msg.content); - const truncated = - content.length > MAX_MESSAGE_LENGTH - ? content.slice(0, MAX_MESSAGE_LENGTH) + '...' - : content; - return `[${role}]: ${truncated}`; + return `[${role}]: ${content}`; }) .join('\n\n'); @@ -105,19 +96,47 @@ export class SessionLearningsService { promptId: 'session-learnings-generation', }); - const summary = getResponseText(response); - if (!summary) { + const summaryText = getResponseText(response); + if (!summaryText) { debugLogger.warn( '[SessionLearnings] Failed to generate summary (empty response)', ); return; } - const filePath = path.join( - this.config.getWorkingDir(), - LEARNINGS_FILENAME, - ); - await fs.writeFile(filePath, summary, 'utf-8'); + // Generate descriptive filename + const summaryService = new SessionSummaryService(baseLlmClient); + const sessionTitle = await summaryService.generateSummary({ + messages: conversation.messages, + }); + + 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( `[SessionLearnings] Saved session learnings to ${filePath}`, );