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

View File

@@ -478,6 +478,7 @@ export interface ConfigParameters {
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
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;
}

View File

@@ -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' }] }],
});

View File

@@ -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}`,
);